diff --git a/demos/src/Commands/Cut/React/index.html b/demos/src/Commands/Cut/React/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/demos/src/Commands/Cut/React/index.jsx b/demos/src/Commands/Cut/React/index.jsx new file mode 100644 index 0000000000..9c498f3d31 --- /dev/null +++ b/demos/src/Commands/Cut/React/index.jsx @@ -0,0 +1,77 @@ +import './styles.scss' + +import { Color } from '@tiptap/extension-color' +import ListItem from '@tiptap/extension-list-item' +import TextStyle from '@tiptap/extension-text-style' +import { EditorContent, useEditor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import React from 'react' + +const MenuBar = ({ editor }) => { + if (!editor) { + return null + } + + return ( + <> + + + + ) +} + +export default () => { + const editor = useEditor({ + extensions: [ + Color.configure({ types: [TextStyle.name, ListItem.name] }), + TextStyle.configure({ types: [ListItem.name] }), + StarterKit.configure({ + bulletList: { + keepMarks: true, + keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help + }, + orderedList: { + keepMarks: true, + keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help + }, + }), + ], + content: ` +

+ Hi there, +

+

+ this is a basic example of tiptap. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists: +

+ +

+ Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block: +

+
body {
+  display: none;
+}
+

+ I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too. +

+
+ Wow, that’s amazing. Good work, boy! 👏 +
+ — Mom +
+ `, + }) + + return ( +
+ + +
+ ) +} diff --git a/demos/src/Commands/Cut/React/index.spec.js b/demos/src/Commands/Cut/React/index.spec.js new file mode 100644 index 0000000000..b23e72c17e --- /dev/null +++ b/demos/src/Commands/Cut/React/index.spec.js @@ -0,0 +1,143 @@ +context('/src/Examples/Default/React/', () => { + before(() => { + cy.visit('/src/Examples/Default/React/') + }) + + beforeEach(() => { + cy.get('.tiptap').then(([{ editor }]) => { + editor.commands.setContent('

Example Text

') + cy.get('.tiptap').type('{selectall}') + }) + }) + + it('should apply the paragraph style when the keyboard shortcut is pressed', () => { + cy.get('.tiptap h1').should('exist') + cy.get('.tiptap p').should('not.exist') + + cy.get('.tiptap') + .trigger('keydown', { modKey: true, altKey: true, key: '0' }) + .find('p') + .should('contain', 'Example Text') + }) + + const buttonMarks = [ + { label: 'bold', tag: 'strong' }, + { label: 'italic', tag: 'em' }, + { label: 'strike', tag: 's' }, + ] + + buttonMarks.forEach(m => { + it(`should disable ${m.label} when the code tag is enabled for cursor`, () => { + cy.get('.tiptap').type('{selectall}Hello world') + cy.get('button').contains('code').click() + cy.get('button').contains(m.label).should('be.disabled') + }) + + it(`should enable ${m.label} when the code tag is disabled for cursor`, () => { + cy.get('.tiptap').type('{selectall}Hello world') + cy.get('button').contains('code').click() + cy.get('button').contains('code').click() + cy.get('button').contains(m.label).should('not.be.disabled') + }) + + it(`should disable ${m.label} when the code tag is enabled for selection`, () => { + cy.get('.tiptap').type('{selectall}Hello world{selectall}') + cy.get('button').contains('code').click() + cy.get('button').contains(m.label).should('be.disabled') + }) + + it(`should enable ${m.label} when the code tag is disabled for selection`, () => { + cy.get('.tiptap').type('{selectall}Hello world{selectall}') + cy.get('button').contains('code').click() + cy.get('button').contains('code').click() + cy.get('button').contains(m.label).should('not.be.disabled') + }) + + it(`should apply ${m.label} when the button is pressed`, () => { + cy.get('.tiptap').type('{selectall}Hello world') + cy.get('button').contains('paragraph').click() + cy.get('.tiptap').type('{selectall}') + cy.get('button').contains(m.label).click() + cy.get(`.tiptap ${m.tag}`).should('exist').should('have.text', 'Hello world') + }) + }) + + it('should clear marks when the button is pressed', () => { + cy.get('.tiptap').type('{selectall}Hello world') + cy.get('button').contains('paragraph').click() + cy.get('.tiptap').type('{selectall}') + cy.get('button').contains('bold').click() + cy.get('.tiptap strong').should('exist').should('have.text', 'Hello world') + cy.get('button').contains('clear marks').click() + cy.get('.tiptap strong').should('not.exist') + }) + + it('should clear nodes when the button is pressed', () => { + cy.get('.tiptap').type('{selectall}Hello world') + cy.get('button').contains('bullet list').click() + cy.get('.tiptap ul').should('exist').should('have.text', 'Hello world') + cy.get('.tiptap').type('{enter}A second item{enter}A third item{selectall}') + cy.get('button').contains('clear nodes').click() + cy.get('.tiptap ul').should('not.exist') + cy.get('.tiptap p').should('have.length', 3) + }) + + const buttonNodes = [ + { label: 'h1', tag: 'h1' }, + { label: 'h2', tag: 'h2' }, + { label: 'h3', tag: 'h3' }, + { label: 'h4', tag: 'h4' }, + { label: 'h5', tag: 'h5' }, + { label: 'h6', tag: 'h6' }, + { label: 'bullet list', tag: 'ul' }, + { label: 'ordered list', tag: 'ol' }, + { label: 'code block', tag: 'pre code' }, + { label: 'blockquote', tag: 'blockquote' }, + ] + + buttonNodes.forEach(n => { + it(`should set ${n.label} when the button is pressed`, () => { + cy.get('button').contains('paragraph').click() + cy.get('.tiptap').type('{selectall}Hello world{selectall}') + + cy.get('button').contains(n.label).click() + cy.get(`.tiptap ${n.tag}`).should('exist').should('have.text', 'Hello world') + cy.get('button').contains(n.label).click() + cy.get(`.tiptap ${n.tag}`).should('not.exist') + }) + }) + + it('should add a hr when on the same line as a node', () => { + cy.get('.tiptap').type('{rightArrow}') + cy.get('button').contains('horizontal rule').click() + cy.get('.tiptap hr').should('exist') + cy.get('.tiptap h1').should('exist') + }) + + it('should add a hr when on a new line', () => { + cy.get('.tiptap').type('{rightArrow}{enter}') + cy.get('button').contains('horizontal rule').click() + cy.get('.tiptap hr').should('exist') + cy.get('.tiptap h1').should('exist') + }) + + it('should add a br', () => { + cy.get('.tiptap').type('{rightArrow}') + cy.get('button').contains('hard break').click() + cy.get('.tiptap h1 br').should('exist') + }) + + it('should undo', () => { + cy.get('.tiptap').type('{selectall}{backspace}') + cy.get('button').contains('undo').click() + cy.get('.tiptap').should('contain', 'Hello world') + }) + + it('should redo', () => { + cy.get('.tiptap').type('{selectall}{backspace}') + cy.get('button').contains('undo').click() + cy.get('.tiptap').should('contain', 'Hello world') + cy.get('button').contains('redo').click() + cy.get('.tiptap').should('not.contain', 'Hello world') + }) +}) diff --git a/demos/src/Commands/Cut/React/styles.scss b/demos/src/Commands/Cut/React/styles.scss new file mode 100644 index 0000000000..4d2b2c81ea --- /dev/null +++ b/demos/src/Commands/Cut/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/demos/src/Nodes/TaskItem/Vue/index.vue b/demos/src/Nodes/TaskItem/Vue/index.vue index 7d1f29f76b..7a0e0517e8 100644 --- a/demos/src/Nodes/TaskItem/Vue/index.vue +++ b/demos/src/Nodes/TaskItem/Vue/index.vue @@ -83,14 +83,14 @@ ul[data-type="taskList"] { > div { flex: 1 1 auto; } - + ul li, ol li { - display: list-item; + display: list-item; } - + ul[data-type="taskList"] > li { - display: flex; + display: flex; } } } diff --git a/demos/src/Nodes/TaskList/Vue/index.vue b/demos/src/Nodes/TaskList/Vue/index.vue index d04d011b0a..7a0e0517e8 100644 --- a/demos/src/Nodes/TaskList/Vue/index.vue +++ b/demos/src/Nodes/TaskList/Vue/index.vue @@ -86,11 +86,11 @@ ul[data-type="taskList"] { ul li, ol li { - display: list-item; + display: list-item; } - + ul[data-type="taskList"] > li { - display: flex; + display: flex; } } } diff --git a/docs/api/commands/cut.md b/docs/api/commands/cut.md new file mode 100644 index 0000000000..22a97a5d0c --- /dev/null +++ b/docs/api/commands/cut.md @@ -0,0 +1,16 @@ +# cut +This command cuts out content and places it into the given position. + +See also: [focus](/api/commands/cut) + +## Usage +```js +const from = editor.state.selection.from +const to = editor.state.selection.to + +const endPos = editor.state.doc.nodeSize - 2 + +// Cut out content from range and put it at the end of the document +editor.commands.cut({ from, to }, endPos) +``` + diff --git a/packages/core/src/commands/cut.ts b/packages/core/src/commands/cut.ts new file mode 100644 index 0000000000..2ee34fb676 --- /dev/null +++ b/packages/core/src/commands/cut.ts @@ -0,0 +1,25 @@ +import { RawCommands } from '../types.js' + +declare module '@tiptap/core' { + interface Commands { + cut: { + /** + * Cuts content from a range and inserts it at a given position. + */ + cut: ({ from, to }: { from: number, to: number }, targetPos: number) => ReturnType, + } + } +} + +export const cut: RawCommands['cut'] = (originRange, targetPos) => ({ editor }) => { + const { state } = editor + + const contentSlice = state.doc.slice(originRange.from, originRange.to) + + return editor + .chain() + .deleteRange(originRange) + .insertContentAt(targetPos, contentSlice.content.toJSON()) + .focus() + .run() +} diff --git a/packages/core/src/commands/index.ts b/packages/core/src/commands/index.ts index 2ab904d32d..954789fa5e 100644 --- a/packages/core/src/commands/index.ts +++ b/packages/core/src/commands/index.ts @@ -3,6 +3,7 @@ export * from './clearContent.js' export * from './clearNodes.js' export * from './command.js' export * from './createParagraphNear.js' +export * from './cut.js' export * from './deleteCurrentNode.js' export * from './deleteNode.js' export * from './deleteRange.js' diff --git a/packages/core/src/commands/insertContentAt.ts b/packages/core/src/commands/insertContentAt.ts index 1836483706..ba30035fc9 100644 --- a/packages/core/src/commands/insertContentAt.ts +++ b/packages/core/src/commands/insertContentAt.ts @@ -46,7 +46,7 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value, return true } - let { from, to } = typeof position === 'number' ? { from: position, to: position } : position + let { from, to } = typeof position === 'number' ? { from: tr.mapping.map(position), to: tr.mapping.map(position) } : { from: tr.mapping.map(position.from), to: tr.mapping.map(position.to) } let isOnlyTextContent = true let isOnlyBlockContent = true