feat: add support for checking for attributes in extendMarkRange

This commit is contained in:
Philipp Kühn
2021-05-17 13:00:54 +02:00
parent 5b8808a899
commit ff7dd9b919
7 changed files with 391 additions and 14 deletions

View File

@@ -1,3 +1,20 @@
# extendMarkRange # extendMarkRange
The `extendMarkRange` command expands the current selection to encompass the current mark. If the current selection doesnt have the specified mark, nothing changes.
<ContentMissing /> ## Parameters
`typeOrName: string | MarkType`
Name or type of the mark.
`attributes?: Record<string, any>`
Optionally, you can specify attributes that the extented mark must contain.
## Usage
```js
// Expand selection to link marks
editor.commands.extendMarkRange('link')
// Expand selection to link marks with specific attributes
editor.commands.extendMarkRange('link', { href: 'https://google.com' })
```

View File

@@ -151,7 +151,6 @@
type: draft type: draft
- title: extendMarkRange - title: extendMarkRange
link: /api/commands/extend-mark-range link: /api/commands/extend-mark-range
type: draft
- title: focus - title: focus
link: /api/commands/focus link: /api/commands/focus
type: draft type: draft

View File

@@ -10,20 +10,20 @@ declare module '@tiptap/core' {
/** /**
* Extends the text selection to the current mark. * Extends the text selection to the current mark.
*/ */
extendMarkRange: (typeOrName: string | MarkType) => Command, extendMarkRange: (typeOrName: string | MarkType, attributes?: Record<string, any>) => Command,
} }
} }
} }
export const extendMarkRange: RawCommands['extendMarkRange'] = typeOrName => ({ tr, state, dispatch }) => { export const extendMarkRange: RawCommands['extendMarkRange'] = (typeOrName, attributes = {}) => ({ tr, state, dispatch }) => {
const type = getMarkType(typeOrName, state.schema) const type = getMarkType(typeOrName, state.schema)
const { doc, selection } = tr const { doc, selection } = tr
const { $from, empty } = selection const { $from, from, to } = selection
if (empty && dispatch) { if (dispatch) {
const range = getMarkRange($from, type) const range = getMarkRange($from, type, attributes)
if (range) { if (range && range.from <= from && range.to >= to) {
const newSelection = TextSelection.create(doc, range.from, range.to) const newSelection = TextSelection.create(doc, range.from, range.to)
tr.setSelection(newSelection) tr.setSelection(newSelection)

View File

@@ -0,0 +1,31 @@
import { Node as ProseMirrorNode } from 'prosemirror-model'
/**
* Returns a node tree with node positions.
*/
export default function getDebugJSON(node: ProseMirrorNode) {
const debug = (startNode: ProseMirrorNode, startOffset = 0) => {
const nodes: any[] = []
startNode.forEach((n, offset) => {
const from = startOffset + offset
const to = from + n.nodeSize
nodes.push({
type: n.type.name,
attrs: { ...n.attrs },
from,
to,
marks: n.marks.map(mark => ({
type: mark.type.name,
attrs: { ...mark.attrs },
})),
content: debug(n, from + 1),
})
})
return nodes
}
return debug(node)
}

View File

@@ -1,7 +1,30 @@
import { MarkType, ResolvedPos } from 'prosemirror-model' import { Mark as ProseMirrorMark, MarkType, ResolvedPos } from 'prosemirror-model'
import objectIncludes from '../utilities/objectIncludes'
import { Range } from '../types' import { Range } from '../types'
export default function getMarkRange($pos: ResolvedPos, type: MarkType): Range | void { function findMarkInSet(
marks: ProseMirrorMark[],
type: MarkType,
attributes: Record<string, any> = {},
): ProseMirrorMark | undefined {
return marks.find(item => {
return item.type === type && objectIncludes(item.attrs, attributes)
})
}
function isMarkInSet(
marks: ProseMirrorMark[],
type: MarkType,
attributes: Record<string, any> = {},
): boolean {
return !!findMarkInSet(marks, type, attributes)
}
export default function getMarkRange(
$pos: ResolvedPos,
type: MarkType,
attributes: Record<string, any> = {},
): Range | void {
if (!$pos || !type) { if (!$pos || !type) {
return return
} }
@@ -12,9 +35,9 @@ export default function getMarkRange($pos: ResolvedPos, type: MarkType): Range |
return return
} }
const link = start.node.marks.find(mark => mark.type === type) const mark = findMarkInSet(start.node.marks, type, attributes)
if (!link) { if (!mark) {
return return
} }
@@ -23,12 +46,17 @@ export default function getMarkRange($pos: ResolvedPos, type: MarkType): Range |
let endIndex = startIndex + 1 let endIndex = startIndex + 1
let endPos = startPos + start.node.nodeSize let endPos = startPos + start.node.nodeSize
while (startIndex > 0 && link.isInSet($pos.parent.child(startIndex - 1).marks)) { findMarkInSet(start.node.marks, type, attributes)
while (startIndex > 0 && mark.isInSet($pos.parent.child(startIndex - 1).marks)) {
startIndex -= 1 startIndex -= 1
startPos -= $pos.parent.child(startIndex).nodeSize startPos -= $pos.parent.child(startIndex).nodeSize
} }
while (endIndex < $pos.parent.childCount && link.isInSet($pos.parent.child(endIndex).marks)) { while (
endIndex < $pos.parent.childCount
&& isMarkInSet($pos.parent.child(endIndex).marks, type, attributes)
) {
endPos += $pos.parent.child(endIndex).nodeSize endPos += $pos.parent.child(endIndex).nodeSize
endIndex += 1 endIndex += 1
} }

View File

@@ -22,6 +22,7 @@ export { default as generateHTML } from './helpers/generateHTML'
export { default as generateJSON } from './helpers/generateJSON' export { default as generateJSON } from './helpers/generateJSON'
export { default as getSchema } from './helpers/getSchema' export { default as getSchema } from './helpers/getSchema'
export { default as getHTMLFromFragment } from './helpers/getHTMLFromFragment' export { default as getHTMLFromFragment } from './helpers/getHTMLFromFragment'
export { default as getDebugJSON } from './helpers/getDebugJSON'
export { default as getAttributes } from './helpers/getAttributes' export { default as getAttributes } from './helpers/getAttributes'
export { default as getMarkAttributes } from './helpers/getMarkAttributes' export { default as getMarkAttributes } from './helpers/getMarkAttributes'
export { default as getMarkRange } from './helpers/getMarkRange' export { default as getMarkRange } from './helpers/getMarkRange'

View File

@@ -0,0 +1,301 @@
/// <reference types="cypress" />
import { Editor, getDebugJSON } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import Link from '@tiptap/extension-link'
describe('extendMarkRange', () => {
it('should extend full mark', () => {
const content = {
type: 'doc',
content: [{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text',
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'foo',
},
},
],
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'bar',
},
},
],
},
{
type: 'text',
text: 'text',
},
],
}],
}
const editor = new Editor({
content,
extensions: [
Document,
Paragraph,
Text,
Link,
],
})
// debug
// console.log(getDebugJSON(editor.state.doc))
// set cursor in middle of first mark
editor
.chain()
.setTextSelection({ from: 7, to: 7 })
.extendMarkRange('link')
.run()
const { from, to } = editor.state.selection
const expectedSelection = {
from: 5,
to: 13,
}
expect({ from, to }).to.deep.eq(expectedSelection)
})
it('should extend to mark with specific attributes', () => {
const content = {
type: 'doc',
content: [{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text',
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'foo',
},
},
],
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'bar',
},
},
],
},
{
type: 'text',
text: 'text',
},
],
}],
}
const editor = new Editor({
content,
extensions: [
Document,
Paragraph,
Text,
Link,
],
})
// debug
// console.log(getDebugJSON(editor.state.doc))
// set cursor in middle of first mark
editor
.chain()
.setTextSelection({ from: 7, to: 7 })
.extendMarkRange('link', {
href: 'foo',
})
.run()
const { from, to } = editor.state.selection
const expectedSelection = {
from: 5,
to: 9,
}
expect({ from, to }).to.deep.eq(expectedSelection)
})
it('should not extend at all if selection contains no mark', () => {
const content = {
type: 'doc',
content: [{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text',
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'foo',
},
},
],
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'bar',
},
},
],
},
{
type: 'text',
text: 'text',
},
],
}],
}
const editor = new Editor({
content,
extensions: [
Document,
Paragraph,
Text,
Link,
],
})
// debug
// console.log(getDebugJSON(editor.state.doc))
// set cursor before any mark
editor
.chain()
.setTextSelection({ from: 2, to: 2 })
.extendMarkRange('link')
.run()
const { from, to } = editor.state.selection
const expectedSelection = {
from: 2,
to: 2,
}
expect({ from, to }).to.deep.eq(expectedSelection)
})
it('should not extend at all if selection contains any non-matching mark', () => {
const content = {
type: 'doc',
content: [{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text',
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'foo',
},
},
],
},
{
type: 'text',
text: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'bar',
},
},
],
},
{
type: 'text',
text: 'text',
},
],
}],
}
const editor = new Editor({
content,
extensions: [
Document,
Paragraph,
Text,
Link,
],
})
// debug
// console.log(getDebugJSON(editor.state.doc))
// set cursor before across non-matching marks
editor
.chain()
.setTextSelection({ from: 7, to: 11 })
.extendMarkRange('link', {
href: 'foo',
})
.run()
const { from, to } = editor.state.selection
const expectedSelection = {
from: 7,
to: 11,
}
expect({ from, to }).to.deep.eq(expectedSelection)
})
})