fix(core): Can() does not work for setting marks (#3223)
Previously, setting marks did no schema validation checks for dry runs (like the `.can()` command). The `setMark` raw command will now properly check if the mark is possible to be set given the editor node/mark schema. Co-authored-by: Cameron Hessler <cameron.hessler@buildertrend.com>
This commit is contained in:
@@ -13,24 +13,52 @@ const MenuBar = ({ editor }) => {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
disabled={
|
||||||
|
!editor.can()
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.toggleBold()
|
||||||
|
.run()
|
||||||
|
}
|
||||||
className={editor.isActive('bold') ? 'is-active' : ''}
|
className={editor.isActive('bold') ? 'is-active' : ''}
|
||||||
>
|
>
|
||||||
bold
|
bold
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
disabled={
|
||||||
|
!editor.can()
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.toggleItalic()
|
||||||
|
.run()
|
||||||
|
}
|
||||||
className={editor.isActive('italic') ? 'is-active' : ''}
|
className={editor.isActive('italic') ? 'is-active' : ''}
|
||||||
>
|
>
|
||||||
italic
|
italic
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
disabled={
|
||||||
|
!editor.can()
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.toggleStrike()
|
||||||
|
.run()
|
||||||
|
}
|
||||||
className={editor.isActive('strike') ? 'is-active' : ''}
|
className={editor.isActive('strike') ? 'is-active' : ''}
|
||||||
>
|
>
|
||||||
strike
|
strike
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
disabled={
|
||||||
|
!editor.can()
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.toggleCode()
|
||||||
|
.run()
|
||||||
|
}
|
||||||
className={editor.isActive('code') ? 'is-active' : ''}
|
className={editor.isActive('code') ? 'is-active' : ''}
|
||||||
>
|
>
|
||||||
code
|
code
|
||||||
@@ -113,10 +141,28 @@ const MenuBar = ({ editor }) => {
|
|||||||
<button onClick={() => editor.chain().focus().setHardBreak().run()}>
|
<button onClick={() => editor.chain().focus().setHardBreak().run()}>
|
||||||
hard break
|
hard break
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => editor.chain().focus().undo().run()}>
|
<button
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
disabled={
|
||||||
|
!editor.can()
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.undo()
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
>
|
||||||
undo
|
undo
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => editor.chain().focus().redo().run()}>
|
<button
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={
|
||||||
|
!editor.can()
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.redo()
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
>
|
||||||
redo
|
redo
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -27,6 +27,32 @@ context('/src/Examples/Default/React/', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
buttonMarks.forEach(m => {
|
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`, () => {
|
it(`should apply ${m.label} when the button is pressed`, () => {
|
||||||
cy.get('.ProseMirror').type('{selectall}Hello world')
|
cy.get('.ProseMirror').type('{selectall}Hello world')
|
||||||
cy.get('button').contains('paragraph').click()
|
cy.get('button').contains('paragraph').click()
|
||||||
|
|||||||
@@ -55,24 +55,28 @@
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
on:click={() => console.log && editor.chain().focus().toggleBold().run()}
|
on:click={() => console.log && editor.chain().focus().toggleBold().run()}
|
||||||
|
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||||
class={editor.isActive("bold") ? "is-active" : ""}
|
class={editor.isActive("bold") ? "is-active" : ""}
|
||||||
>
|
>
|
||||||
bold
|
bold
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={() => editor.chain().focus().toggleItalic().run()}
|
on:click={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||||
class={editor.isActive("italic") ? "is-active" : ""}
|
class={editor.isActive("italic") ? "is-active" : ""}
|
||||||
>
|
>
|
||||||
italic
|
italic
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={() => editor.chain().focus().toggleStrike().run()}
|
on:click={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
disabled={!editor.can().chain().focus().toggleStrike().run()}
|
||||||
class={editor.isActive("strike") ? "is-active" : ""}
|
class={editor.isActive("strike") ? "is-active" : ""}
|
||||||
>
|
>
|
||||||
strike
|
strike
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={() => editor.chain().focus().toggleCode().run()}
|
on:click={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
disabled={!editor.can().chain().focus().toggleCode().run()}
|
||||||
class={editor.isActive("code") ? "is-active" : ""}
|
class={editor.isActive("code") ? "is-active" : ""}
|
||||||
>
|
>
|
||||||
code
|
code
|
||||||
@@ -149,8 +153,18 @@
|
|||||||
horizontal rule
|
horizontal rule
|
||||||
</button>
|
</button>
|
||||||
<button on:click={() => editor.chain().focus().setHardBreak().run()}> hard break </button>
|
<button on:click={() => editor.chain().focus().setHardBreak().run()}> hard break </button>
|
||||||
<button on:click={() => editor.chain().focus().undo().run()}> undo </button>
|
<button
|
||||||
<button on:click={() => editor.chain().focus().redo().run()}> redo </button>
|
on:click={() => editor.chain().focus().undo().run()}
|
||||||
|
disabled={!editor.can().chain().focus().undo().run()}
|
||||||
|
>
|
||||||
|
undo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={!editor.can().chain().focus().redo().run()}
|
||||||
|
>
|
||||||
|
redo
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="editor">
|
<div v-if="editor">
|
||||||
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }">
|
<button @click="editor.chain().focus().toggleBold().run()" :disabled="!editor.can().chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }">
|
||||||
bold
|
bold
|
||||||
</button>
|
</button>
|
||||||
<button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }">
|
<button @click="editor.chain().focus().toggleItalic().run()" :disabled="!editor.can().chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }">
|
||||||
italic
|
italic
|
||||||
</button>
|
</button>
|
||||||
<button @click="editor.chain().focus().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }">
|
<button @click="editor.chain().focus().toggleStrike().run()" :disabled="!editor.can().chain().focus().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }">
|
||||||
strike
|
strike
|
||||||
</button>
|
</button>
|
||||||
<button @click="editor.chain().focus().toggleCode().run()" :class="{ 'is-active': editor.isActive('code') }">
|
<button @click="editor.chain().focus().toggleCode().run()" :disabled="!editor.can().chain().focus().toggleCode().run()" :class="{ 'is-active': editor.isActive('code') }">
|
||||||
code
|
code
|
||||||
</button>
|
</button>
|
||||||
<button @click="editor.chain().focus().unsetAllMarks().run()">
|
<button @click="editor.chain().focus().unsetAllMarks().run()">
|
||||||
@@ -57,10 +57,10 @@
|
|||||||
<button @click="editor.chain().focus().setHardBreak().run()">
|
<button @click="editor.chain().focus().setHardBreak().run()">
|
||||||
hard break
|
hard break
|
||||||
</button>
|
</button>
|
||||||
<button @click="editor.chain().focus().undo().run()">
|
<button @click="editor.chain().focus().undo().run()" :disabled="!editor.can().chain().focus().undo().run()">
|
||||||
undo
|
undo
|
||||||
</button>
|
</button>
|
||||||
<button @click="editor.chain().focus().redo().run()">
|
<button @click="editor.chain().focus().redo().run()" :disabled="!editor.can().chain().focus().redo().run()">
|
||||||
redo
|
redo
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { getMarkAttributes } from '../helpers/getMarkAttributes'
|
||||||
import { getMarkType } from '../helpers/getMarkType'
|
import { getMarkType } from '../helpers/getMarkType'
|
||||||
import { RawCommands } from '../types'
|
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 }) => {
|
export const setMark: RawCommands['setMark'] = (typeOrName, attributes = {}) => ({ tr, state, dispatch }) => {
|
||||||
const { selection } = tr
|
const { selection } = tr
|
||||||
const { empty, ranges } = selection
|
const { empty, ranges } = selection
|
||||||
@@ -42,6 +83,7 @@ export const setMark: RawCommands['setMark'] = (typeOrName, attributes = {}) =>
|
|||||||
// we know that we have to merge its attributes
|
// we know that we have to merge its attributes
|
||||||
// otherwise we add a fresh new mark
|
// otherwise we add a fresh new mark
|
||||||
if (someHasMark) {
|
if (someHasMark) {
|
||||||
|
|
||||||
node.marks.forEach(mark => {
|
node.marks.forEach(mark => {
|
||||||
if (type === mark.type) {
|
if (type === mark.type) {
|
||||||
tr.addMark(trimmedFrom, trimmedTo, type.create({
|
tr.addMark(trimmedFrom, trimmedTo, type.create({
|
||||||
@@ -58,5 +100,5 @@ export const setMark: RawCommands['setMark'] = (typeOrName, attributes = {}) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return canSetMark(state, tr, type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/// <reference types="cypress" />
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
import { Editor } from '@tiptap/core'
|
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 Document from '@tiptap/extension-document'
|
||||||
import History from '@tiptap/extension-history'
|
import History from '@tiptap/extension-history'
|
||||||
import Paragraph from '@tiptap/extension-paragraph'
|
import Paragraph from '@tiptap/extension-paragraph'
|
||||||
@@ -70,4 +73,174 @@ describe('can', () => {
|
|||||||
|
|
||||||
expect(canUndo).to.eq(true)
|
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('<code>test</code>')
|
||||||
|
.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('<code>test</code>').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('<code>test</code><code>123</code>').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('<code>test</code>').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('<code>test</code>')
|
||||||
|
.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('<code>test</code><i>123</i>').selectAll().run()
|
||||||
|
|
||||||
|
const canSetMarkToBold = editor.can().setMark('bold')
|
||||||
|
|
||||||
|
expect(canSetMarkToBold).to.eq(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user