Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add insertContent logic to setContent #4895

Merged
merged 9 commits into from
Jun 25, 2024
50 changes: 50 additions & 0 deletions demos/src/Commands/InsertContent/React/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,54 @@ context('/src/Commands/InsertContent/React/', () => {
cy.get('.tiptap').should('contain.html', '<pre><code>foo\nbar</code></pre>')
})
})

it('should keep newlines and tabs', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.insertContent('<p>Hello\n\tworld\n\t\thow\n\t\t\tnice.\ntest\tOK</p>')
cy.get('.tiptap').should('contain.html', '<p>Hello\n\tworld\n\t\thow\n\t\t\tnice.\ntest\tOK</p>')
})
})

it('should keep newlines and tabs', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.insertContent('<h1>Tiptap</h1>\n<p><strong>Hello World</strong></p>')
cy.get('.tiptap').should('contain.html', '<h1>Tiptap</h1><p><strong>Hello World</strong></p>')
})
})

it('should allow inserting nothing', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.insertContent('')
cy.get('.tiptap').should('contain.html', '')
})
})

it('should allow inserting a partial HTML tag', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.insertContent('<p>foo')
cy.get('.tiptap').should('contain.html', '<p>foo</p>')
})
})

it('should allow inserting an incomplete HTML tag', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.insertContent('foo<p')
cy.get('.tiptap').should('contain.html', '<p>foo&lt;p</p>')
})
})

it('should allow inserting a list', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.insertContent('<ul><li>ABC</li><li>123</li></ul>')
cy.get('.tiptap').should('contain.html', '<ul><li><p>ABC</p></li><li><p>123</p></li></ul>')
})
})

it('should remove newlines and tabs when parseOptions.preserveWhitespace=false', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.insertContent('\n<h1>Tiptap</h1><p><strong>Hello\n World</strong>\n</p>\n', { parseOptions: { preserveWhitespace: false } })
cy.get('.tiptap').should('contain.html', '<h1>Tiptap</h1><p><strong>Hello World</strong></p>')
})
})

})
Empty file.
31 changes: 31 additions & 0 deletions demos/src/Commands/SetContent/React/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import './styles.scss'

import { Color } from '@tiptap/extension-color'
import ListItem from '@tiptap/extension-list-item'
import Mentions from '@tiptap/extension-mention'
import TextStyle from '@tiptap/extension-text-style'
import { EditorProvider } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'

const extensions = [
Color.configure({ types: [TextStyle.name, ListItem.name] }),
TextStyle.configure({ types: [ListItem.name] }),
StarterKit.configure({
bulletList: {
keepMarks: true,
},
orderedList: {
keepMarks: true,
},
}),
Mentions,
]

const content = ''

export default () => {
return (
<EditorProvider extensions={extensions} content={content}></EditorProvider>
)
}
160 changes: 160 additions & 0 deletions demos/src/Commands/SetContent/React/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
context('/src/Commands/SetContent/React/', () => {
before(() => {
cy.visit('/src/Commands/SetContent/React/')
})

beforeEach(() => {
cy.get('.tiptap').type('{selectall}{backspace}')
})

it('should insert raw text content', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('Hello World.')
cy.get('.tiptap').should('contain.html', '<p>Hello World.</p>')
})
})

it('should emit updates', () => {
cy.get('.tiptap').then(([{ editor }]) => {
let updateCount = 0
const callback = () => {
updateCount += 1
}

editor.on('update', callback)
// emit an update
editor.commands.setContent('Hello World.', true)
expect(updateCount).to.equal(1)

updateCount = 0
// do not emit an update
editor.commands.setContent('Hello World again.', false)
expect(updateCount).to.equal(0)
editor.off('update', callback)
})
})

it('should insert more complex html content', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<h1>Welcome to Tiptap</h1><p>This is a paragraph.</p><ul><li><p>List Item A</p></li><li><p>List Item B</p><ul><li><p>Subchild</p></li></ul></li></ul>')
cy.get('.tiptap').should('contain.html', '<h1>Welcome to Tiptap</h1><p>This is a paragraph.</p><ul><li><p>List Item A</p></li><li><p>List Item B</p><ul><li><p>Subchild</p></li></ul></li></ul>')
})
})

it('should remove newlines and tabs', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<p>Hello\n\tworld\n\t\thow\n\t\t\tnice.</p>')
cy.get('.tiptap').should('contain.html', '<p>Hello world how nice.</p>')
})
})

it('should keep newlines and tabs when preserveWhitespace = full', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<p>Hello\n\tworld\n\t\thow\n\t\t\tnice.</p>', false, { preserveWhitespace: 'full' })
cy.get('.tiptap').should('contain.html', '<p>Hello\n\tworld\n\t\thow\n\t\t\tnice.</p>')
})
})

it('should overwrite existing content', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<p>Initial Content</p>')
cy.get('.tiptap').should('contain.html', '<p>Initial Content</p>')
})
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<p>Overwritten Content</p>')
cy.get('.tiptap').should('contain.html', '<p>Overwritten Content</p>')
})
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('Content without tags')
cy.get('.tiptap').should('contain.html', '<p>Content without tags</p>')
})
})

it('should insert mentions', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<p><span data-type="mention" data-id="1" data-label="John Doe">@John Doe</span></p>')
cy.get('.tiptap').should('contain.html', '<span data-type="mention" data-id="1" data-label="John Doe" contenteditable="false">@John Doe</span>')
})
})

it('should remove newlines and tabs between html fragments', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<h1>Tiptap</h1>\n\t<p><strong>Hello World</strong></p>')
cy.get('.tiptap').should('contain.html', '<h1>Tiptap</h1><p><strong>Hello World</strong></p>')
})
})

// TODO I'm not certain about this behavior and what it should do...
// This exists in insertContentAt as well
it('should keep newlines and tabs between html fragments when preserveWhitespace = full', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<h1>Tiptap</h1>\n\t<p><strong>Hello World</strong></p>', false, { preserveWhitespace: 'full' })
cy.get('.tiptap').should('contain.html', '<h1>Tiptap</h1><p>\n\t</p><p><strong>Hello World</strong></p>')
})
})

it('should allow inserting nothing', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('')
cy.get('.tiptap').should('contain.html', '')
})
})

it('should allow inserting nothing when preserveWhitespace = full', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('', false, { preserveWhitespace: 'full' })
cy.get('.tiptap').should('contain.html', '')
})
})

it('should allow inserting a partial HTML tag', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<p>foo')
cy.get('.tiptap').should('contain.html', '<p>foo</p>')
})
})

it('should allow inserting a partial HTML tag when preserveWhitespace = full', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<p>foo', false, { preserveWhitespace: 'full' })
cy.get('.tiptap').should('contain.html', '<p>foo</p>')
})
})

it('will remove an incomplete HTML tag', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('foo<p')
cy.get('.tiptap').should('contain.html', '<p>foo</p>')
})
})

// TODO I'm not certain about this behavior and what it should do...
// This exists in insertContentAt as well
it('should allow inserting an incomplete HTML tag when preserveWhitespace = full', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('foo<p', false, { preserveWhitespace: 'full' })
cy.get('.tiptap').should('contain.html', '<p>foo&lt;p</p>')
})
})

it('should allow inserting a list', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<ul><li>ABC</li><li>123</li></ul>')
cy.get('.tiptap').should('contain.html', '<ul><li><p>ABC</p></li><li><p>123</p></li></ul>')
})
})

it('should allow inserting a list when preserveWhitespace = full', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<ul><li>ABC</li><li>123</li></ul>', false, { preserveWhitespace: 'full' })
cy.get('.tiptap').should('contain.html', '<ul><li><p>ABC</p></li><li><p>123</p></li></ul>')
})
})

it('should remove newlines and tabs when parseOptions.preserveWhitespace=false', () => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('\n<h1>Tiptap</h1><p><strong>Hello\n World</strong>\n</p>\n', false, { preserveWhitespace: false })
cy.get('.tiptap').should('contain.html', '<h1>Tiptap</h1><p><strong>Hello World</strong></p>')
})
})
})
56 changes: 56 additions & 0 deletions demos/src/Commands/SetContent/React/styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/* Basic editor styles */
.tiptap {
> * + * {
margin-top: 0.75em;
}

ul,
ol {
padding: 0 1rem;
}

h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
}

code {
background-color: rgba(#616161, 0.1);
color: #616161;
}

pre {
background: #0D0D0D;
color: #FFF;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;

code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
}

img {
max-width: 100%;
height: auto;
}

blockquote {
padding-left: 1rem;
border-left: 2px solid rgba(#0D0D0D, 0.1);
}

hr {
border: none;
border-top: 2px solid rgba(#0D0D0D, 0.1);
margin: 2rem 0;
}
}
5 changes: 0 additions & 5 deletions packages/core/src/commands/insertContentAt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,6 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value,
return false
}

// don’t dispatch an empty fragment because this can lead to strange errors
if (content.toString() === '<>') {
return true
}

Comment on lines -87 to -91
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was removed because commands.clearContent relies on setContent to be able to reset the document to the initial schema. This was early exiting and causing a no-op making clearContent not actually clear anything.

This is very old code that wasn't very well documented but after some testing, I'm confident in this not "leading to strange errors". insertContentAt is much more robust than when this was written.

I tested inserting empty content into a node and a block and it essentially resulted in a no-op.

let { from, to } = typeof position === 'number' ? { from: position, to: position } : { from: position.from, to: position.to }

let isOnlyTextContent = true
Expand Down
32 changes: 22 additions & 10 deletions packages/core/src/commands/setContent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, Node as ProseMirrorNode, ParseOptions } from '@tiptap/pm/model'
import { ParseOptions } from '@tiptap/pm/model'

import { createDocument } from '../helpers/createDocument.js'
import { Content, RawCommands } from '../types.js'
Expand Down Expand Up @@ -44,22 +44,34 @@ declare module '@tiptap/core' {
}
}

export const setContent: RawCommands['setContent'] = (content, emitUpdate = false, parseOptions = {}, options = {}) => ({ tr, editor, dispatch }) => {
export const setContent: RawCommands['setContent'] = (content, emitUpdate = false, parseOptions = {}, options = {}) => ({
editor, tr, dispatch, commands,
}) => {
const { doc } = tr

let document: Fragment | ProseMirrorNode

try {
document = createDocument(content, editor.schema, parseOptions, {
// This is to keep backward compatibility with the previous behavior
// TODO remove this in the next major version
if (parseOptions.preserveWhitespace !== 'full') {
const document = createDocument(content, editor.schema, parseOptions, {
errorOnInvalidContent: options.errorOnInvalidContent ?? editor.options.enableContentCheck,
})
} catch (e) {
return false

if (dispatch) {
tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', !emitUpdate)
}
return true
}

if (dispatch) {
tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', !emitUpdate)
tr.setMeta('preventUpdate', !emitUpdate)
}

return true
return commands.insertContentAt(
{ from: 0, to: doc.content.size },
content,
{
parseOptions,
errorOnInvalidContent: options.errorOnInvalidContent ?? editor.options.enableContentCheck,
},
)
}