diff --git a/demos/src/Examples/Default/React/index.jsx b/demos/src/Examples/Default/React/index.jsx index ead2c91b..42c168ad 100644 --- a/demos/src/Examples/Default/React/index.jsx +++ b/demos/src/Examples/Default/React/index.jsx @@ -13,24 +13,52 @@ const MenuBar = ({ editor }) => { <> editor.chain().focus().toggleBold().run()} + disabled={ + !editor.can() + .chain() + .focus() + .toggleBold() + .run() + } className={editor.isActive('bold') ? 'is-active' : ''} > bold editor.chain().focus().toggleItalic().run()} + disabled={ + !editor.can() + .chain() + .focus() + .toggleItalic() + .run() + } className={editor.isActive('italic') ? 'is-active' : ''} > italic editor.chain().focus().toggleStrike().run()} + disabled={ + !editor.can() + .chain() + .focus() + .toggleStrike() + .run() + } className={editor.isActive('strike') ? 'is-active' : ''} > strike editor.chain().focus().toggleCode().run()} + disabled={ + !editor.can() + .chain() + .focus() + .toggleCode() + .run() + } className={editor.isActive('code') ? 'is-active' : ''} > code @@ -113,10 +141,28 @@ const MenuBar = ({ editor }) => { editor.chain().focus().setHardBreak().run()}> hard break - editor.chain().focus().undo().run()}> + editor.chain().focus().undo().run()} + disabled={ + !editor.can() + .chain() + .focus() + .undo() + .run() + } + > undo - editor.chain().focus().redo().run()}> + editor.chain().focus().redo().run()} + disabled={ + !editor.can() + .chain() + .focus() + .redo() + .run() + } + > redo > diff --git a/demos/src/Examples/Default/React/index.spec.js b/demos/src/Examples/Default/React/index.spec.js index b6702ded..02720962 100644 --- a/demos/src/Examples/Default/React/index.spec.js +++ b/demos/src/Examples/Default/React/index.spec.js @@ -27,6 +27,32 @@ context('/src/Examples/Default/React/', () => { ] buttonMarks.forEach(m => { + it(`should disable ${m.label} when the code tag is enabled for cursor`, () => { + cy.get('.ProseMirror').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('.ProseMirror').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('.ProseMirror').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('.ProseMirror').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('.ProseMirror').type('{selectall}Hello world') cy.get('button').contains('paragraph').click() diff --git a/demos/src/Examples/Default/Svelte/index.svelte b/demos/src/Examples/Default/Svelte/index.svelte index 391f26ab..d43c0f0e 100644 --- a/demos/src/Examples/Default/Svelte/index.svelte +++ b/demos/src/Examples/Default/Svelte/index.svelte @@ -55,24 +55,28 @@ console.log && editor.chain().focus().toggleBold().run()} + disabled={!editor.can().chain().focus().toggleBold().run()} class={editor.isActive("bold") ? "is-active" : ""} > bold editor.chain().focus().toggleItalic().run()} + disabled={!editor.can().chain().focus().toggleItalic().run()} class={editor.isActive("italic") ? "is-active" : ""} > italic editor.chain().focus().toggleStrike().run()} + disabled={!editor.can().chain().focus().toggleStrike().run()} class={editor.isActive("strike") ? "is-active" : ""} > strike editor.chain().focus().toggleCode().run()} + disabled={!editor.can().chain().focus().toggleCode().run()} class={editor.isActive("code") ? "is-active" : ""} > code @@ -149,8 +153,18 @@ horizontal rule editor.chain().focus().setHardBreak().run()}> hard break - editor.chain().focus().undo().run()}> undo - editor.chain().focus().redo().run()}> redo + editor.chain().focus().undo().run()} + disabled={!editor.can().chain().focus().undo().run()} + > + undo + + editor.chain().focus().redo().run()} + disabled={!editor.can().chain().focus().redo().run()} + > + redo + {/if} diff --git a/demos/src/Examples/Default/Vue/index.vue b/demos/src/Examples/Default/Vue/index.vue index d47ee9dd..6fe89b63 100644 --- a/demos/src/Examples/Default/Vue/index.vue +++ b/demos/src/Examples/Default/Vue/index.vue @@ -1,15 +1,15 @@ - + bold - + italic - + strike - + code @@ -57,10 +57,10 @@ hard break - + undo - + redo diff --git a/packages/core/src/commands/setMark.ts b/packages/core/src/commands/setMark.ts index 346188f3..79bdc4d3 100644 --- a/packages/core/src/commands/setMark.ts +++ b/packages/core/src/commands/setMark.ts @@ -1,5 +1,7 @@ -import { MarkType } from 'prosemirror-model' +import { MarkType, ResolvedPos } from 'prosemirror-model' +import { EditorState, Transaction } from 'prosemirror-state' +import { isTextSelection } from '../helpers' import { getMarkAttributes } from '../helpers/getMarkAttributes' import { getMarkType } from '../helpers/getMarkType' import { RawCommands } from '../types' @@ -15,6 +17,45 @@ declare module '@tiptap/core' { } } +function canSetMark(state: EditorState, tr: Transaction, newMarkType: MarkType) { + const { selection } = tr + let cursor: ResolvedPos | null = null + + if (isTextSelection(selection)) { + cursor = selection.$cursor + } + + if (cursor) { + const currentMarks = state.storedMarks ?? cursor.marks() + + // There can be no current marks that exclude the new mark + return !!newMarkType.isInSet(currentMarks) || !currentMarks.some(mark => mark.type.excludes(newMarkType)) + } + + const { ranges } = selection + + return ranges.some(({ $from, $to }) => { + let someNodeSupportsMark = $from.depth === 0 ? state.doc.inlineContent && state.doc.type.allowsMarkType(newMarkType) : false + + state.doc.nodesBetween($from.pos, $to.pos, (node, _pos, parent) => { + // If we already found a mark that we can enable, return false to bypass the remaining search + if (someNodeSupportsMark) { + return false + } + + if (node.isInline) { + const parentAllowsMarkType = !parent || parent.type.allowsMarkType(newMarkType) + const currentMarksAllowMarkType = !!newMarkType.isInSet(node.marks) || !node.marks.some(otherMark => otherMark.type.excludes(newMarkType)) + + someNodeSupportsMark = parentAllowsMarkType && currentMarksAllowMarkType + } + return !someNodeSupportsMark + }) + + return someNodeSupportsMark + }) + +} export const setMark: RawCommands['setMark'] = (typeOrName, attributes = {}) => ({ tr, state, dispatch }) => { const { selection } = tr const { empty, ranges } = selection @@ -42,6 +83,7 @@ export const setMark: RawCommands['setMark'] = (typeOrName, attributes = {}) => // we know that we have to merge its attributes // otherwise we add a fresh new mark if (someHasMark) { + node.marks.forEach(mark => { if (type === mark.type) { tr.addMark(trimmedFrom, trimmedTo, type.create({ @@ -58,5 +100,5 @@ export const setMark: RawCommands['setMark'] = (typeOrName, attributes = {}) => } } - return true + return canSetMark(state, tr, type) } diff --git a/tests/cypress/integration/core/can.spec.ts b/tests/cypress/integration/core/can.spec.ts index f8b1bf68..f4f361cb 100644 --- a/tests/cypress/integration/core/can.spec.ts +++ b/tests/cypress/integration/core/can.spec.ts @@ -1,6 +1,9 @@ /// import { Editor } from '@tiptap/core' +import Bold from '@tiptap/extension-bold' +import Code from '@tiptap/extension-code' +import CodeBlock from '@tiptap/extension-code-block' import Document from '@tiptap/extension-document' import History from '@tiptap/extension-history' import Paragraph from '@tiptap/extension-paragraph' @@ -70,4 +73,174 @@ describe('can', () => { expect(canUndo).to.eq(true) }) + + it('returns false for non-applicable marks when selection contains node in conflict', () => { + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + History, + CodeBlock, + Bold, + ], + }) + + editor.chain() + .setCodeBlock() + .insertContent('Test code block') + .setTextSelection({ from: 2, to: 3 }) + .selectAll() + .run() + + const canSetMarkToBold = editor.can().setMark('bold') + + expect(canSetMarkToBold).to.eq(false) + }) + + it('returns false for non-applicable marks when selection contains marks in conflict', () => { + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + History, + Code, + Bold, + ], + }) + + editor.chain() + .setContent('test') + .setTextSelection({ from: 2, to: 3 }) + .run() + + const canSetMarkToBold = editor.can().setMark('bold') + + expect(canSetMarkToBold).to.eq(false) + }) + + it('returns false for non-applicable marks when stored marks in conflict', () => { + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + History, + Code, + Bold, + ], + }) + + editor.chain().setContent('test').run() + + const canSetMarkToBold = editor.can().setMark('bold') + + expect(canSetMarkToBold).to.eq(false) + }) + + it('returns false for non-applicable marks when selecting multiple nodes in conflict', () => { + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + History, + Code, + Bold, + ], + }) + + editor.chain().setContent('test123').selectAll().run() + + const canSetMarkToBold = editor.can().setMark('bold') + + expect(canSetMarkToBold).to.eq(false) + }) + + it('returns true for applicable marks when selection does not contain nodes in conflict', () => { + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + History, + CodeBlock, + Bold, + ], + }) + + editor.chain() + .setCodeBlock() + .insertContent('Test code block') + .exitCode() + .insertContent('Additional paragraph node') + .selectAll() + .run() + + const canSetMarkToBold = editor.can().setMark('bold') + + expect(canSetMarkToBold).to.eq(true) + }) + + it('returns true for applicable marks when stored marks are not in conflict', () => { + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + History, + Code, + Bold, + ], + }) + + editor.chain().setContent('test').toggleCode().run() + + const canSetMarkToBold = editor.can().setMark('bold') + + expect(canSetMarkToBold).to.eq(true) + }) + + it('returns true for applicable marks when selection does not contain marks in conflict', () => { + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + History, + Code, + Bold, + ], + }) + + editor.chain() + .setContent('test') + .setTextSelection({ from: 2, to: 3 }) + .toggleCode() + .run() + + const canSetMarkToBold = editor.can().setMark('bold') + + expect(canSetMarkToBold).to.eq(true) + }) + + it('returns true for applicable marks if at least one node in selection has no marks in conflict', () => { + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + History, + Code, + Bold, + ], + }) + + editor.chain().setContent('test123').selectAll().run() + + const canSetMarkToBold = editor.can().setMark('bold') + + expect(canSetMarkToBold).to.eq(true) + }) })
test
123