diff --git a/demos/src/Commands/InsertContent/React/index.spec.js b/demos/src/Commands/InsertContent/React/index.spec.js index 153d205761..9bfe708377 100644 --- a/demos/src/Commands/InsertContent/React/index.spec.js +++ b/demos/src/Commands/InsertContent/React/index.spec.js @@ -41,4 +41,54 @@ context('/src/Commands/InsertContent/React/', () => { cy.get('.tiptap').should('contain.html', '
foo\nbar
') }) }) + + it('should keep newlines and tabs', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.insertContent('

Hello\n\tworld\n\t\thow\n\t\t\tnice.\ntest\tOK

') + cy.get('.tiptap').should('contain.html', '

Hello\n\tworld\n\t\thow\n\t\t\tnice.\ntest\tOK

') + }) + }) + + it('should keep newlines and tabs', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.insertContent('

Tiptap

\n

Hello World

') + cy.get('.tiptap').should('contain.html', '

Tiptap

Hello World

') + }) + }) + + 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('

foo') + cy.get('.tiptap').should('contain.html', '

foo

') + }) + }) + + it('should allow inserting an incomplete HTML tag', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.insertContent('foofoo<p

') + }) + }) + + it('should allow inserting a list', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.insertContent('') + cy.get('.tiptap').should('contain.html', '') + }) + }) + + it('should remove newlines and tabs when parseOptions.preserveWhitespace=false', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.insertContent('\n

Tiptap

Hello\n World\n

\n', { parseOptions: { preserveWhitespace: false } }) + cy.get('.tiptap').should('contain.html', '

Tiptap

Hello World

') + }) + }) + }) diff --git a/demos/src/Commands/SetContent/React/index.html b/demos/src/Commands/SetContent/React/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/demos/src/Commands/SetContent/React/index.jsx b/demos/src/Commands/SetContent/React/index.jsx new file mode 100644 index 0000000000..b8810baebd --- /dev/null +++ b/demos/src/Commands/SetContent/React/index.jsx @@ -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 ( + + ) +} diff --git a/demos/src/Commands/SetContent/React/index.spec.js b/demos/src/Commands/SetContent/React/index.spec.js new file mode 100644 index 0000000000..f6fbdf20ea --- /dev/null +++ b/demos/src/Commands/SetContent/React/index.spec.js @@ -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', '

Hello World.

') + }) + }) + + 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('

Welcome to Tiptap

This is a paragraph.

') + cy.get('.tiptap').should('contain.html', '

Welcome to Tiptap

This is a paragraph.

') + }) + }) + + it('should remove newlines and tabs', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent('

Hello\n\tworld\n\t\thow\n\t\t\tnice.

') + cy.get('.tiptap').should('contain.html', '

Hello world how nice.

') + }) + }) + + it('should keep newlines and tabs when preserveWhitespace = full', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent('

Hello\n\tworld\n\t\thow\n\t\t\tnice.

', false, { preserveWhitespace: 'full' }) + cy.get('.tiptap').should('contain.html', '

Hello\n\tworld\n\t\thow\n\t\t\tnice.

') + }) + }) + + it('should overwrite existing content', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent('

Initial Content

') + cy.get('.tiptap').should('contain.html', '

Initial Content

') + }) + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent('

Overwritten Content

') + cy.get('.tiptap').should('contain.html', '

Overwritten Content

') + }) + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent('Content without tags') + cy.get('.tiptap').should('contain.html', '

Content without tags

') + }) + }) + + it('should insert mentions', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent('

@John Doe

') + cy.get('.tiptap').should('contain.html', '@John Doe') + }) + }) + + it('should remove newlines and tabs between html fragments', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent('

Tiptap

\n\t

Hello World

') + cy.get('.tiptap').should('contain.html', '

Tiptap

Hello World

') + }) + }) + + // 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('

Tiptap

\n\t

Hello World

', false, { preserveWhitespace: 'full' }) + cy.get('.tiptap').should('contain.html', '

Tiptap

\n\t

Hello World

') + }) + }) + + 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('

foo') + cy.get('.tiptap').should('contain.html', '

foo

') + }) + }) + + it('should allow inserting a partial HTML tag when preserveWhitespace = full', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent('

foo', false, { preserveWhitespace: 'full' }) + cy.get('.tiptap').should('contain.html', '

foo

') + }) + }) + + it('will remove an incomplete HTML tag', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent('foofoo

') + }) + }) + + // 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('foofoo<p

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

  • 123

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

  • 123

') + }) + }) + + it('should remove newlines and tabs when parseOptions.preserveWhitespace=false', () => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent('\n

Tiptap

Hello\n World\n

\n', false, { preserveWhitespace: false }) + cy.get('.tiptap').should('contain.html', '

Tiptap

Hello World

') + }) + }) +}) diff --git a/demos/src/Commands/SetContent/React/styles.scss b/demos/src/Commands/SetContent/React/styles.scss new file mode 100644 index 0000000000..4d2b2c81ea --- /dev/null +++ b/demos/src/Commands/SetContent/React/styles.scss @@ -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; + } +} diff --git a/packages/core/src/commands/insertContentAt.ts b/packages/core/src/commands/insertContentAt.ts index f1159c6f10..996cd511fd 100644 --- a/packages/core/src/commands/insertContentAt.ts +++ b/packages/core/src/commands/insertContentAt.ts @@ -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 - } - let { from, to } = typeof position === 'number' ? { from: position, to: position } : { from: position.from, to: position.to } let isOnlyTextContent = true diff --git a/packages/core/src/commands/setContent.ts b/packages/core/src/commands/setContent.ts index cd26fa6f8e..0819c3cd05 100644 --- a/packages/core/src/commands/setContent.ts +++ b/packages/core/src/commands/setContent.ts @@ -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, + }, + ) }