feat(core): normalize setContent and insertContentAt (#4895)

When `parseOptions.whitespace === 'full' or parseOptions.whitespace === true` setting content will no longer strip whitespaces on setContent
This commit is contained in:
bdbch 2024-06-25 16:50:46 +02:00 committed by GitHub
parent 2198991fda
commit 81d3b8a671
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 319 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -84,11 +84,6 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value,
return false
}
// dont dispatch an empty fragment because this can lead to strange errors
if (content.toString() === '<>') {
return true
}
let { from, to } = typeof position === 'number' ? { from: position, to: position } : { from: position.from, to: position.to }
let isOnlyTextContent = true

View File

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