diff --git a/docs/src/docPages/api/commands/extend-mark-range.md b/docs/src/docPages/api/commands/extend-mark-range.md
index 714f9244..511a85e4 100644
--- a/docs/src/docPages/api/commands/extend-mark-range.md
+++ b/docs/src/docPages/api/commands/extend-mark-range.md
@@ -1,3 +1,20 @@
# extendMarkRange
+The `extendMarkRange` command expands the current selection to encompass the current mark. If the current selection doesn’t have the specified mark, nothing changes.
-
+## Parameters
+`typeOrName: string | MarkType`
+
+Name or type of the mark.
+
+`attributes?: Record`
+
+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' })
+```
diff --git a/docs/src/links.yaml b/docs/src/links.yaml
index 0c84ccfa..3456f502 100644
--- a/docs/src/links.yaml
+++ b/docs/src/links.yaml
@@ -151,7 +151,6 @@
type: draft
- title: extendMarkRange
link: /api/commands/extend-mark-range
- type: draft
- title: focus
link: /api/commands/focus
type: draft
diff --git a/packages/core/src/commands/extendMarkRange.ts b/packages/core/src/commands/extendMarkRange.ts
index 9ad5c3c6..793334f7 100644
--- a/packages/core/src/commands/extendMarkRange.ts
+++ b/packages/core/src/commands/extendMarkRange.ts
@@ -10,20 +10,20 @@ declare module '@tiptap/core' {
/**
* Extends the text selection to the current mark.
*/
- extendMarkRange: (typeOrName: string | MarkType) => Command,
+ extendMarkRange: (typeOrName: string | MarkType, attributes?: Record) => 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 { doc, selection } = tr
- const { $from, empty } = selection
+ const { $from, from, to } = selection
- if (empty && dispatch) {
- const range = getMarkRange($from, type)
+ if (dispatch) {
+ 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)
tr.setSelection(newSelection)
diff --git a/packages/core/src/helpers/getDebugJSON.ts b/packages/core/src/helpers/getDebugJSON.ts
new file mode 100644
index 00000000..00449638
--- /dev/null
+++ b/packages/core/src/helpers/getDebugJSON.ts
@@ -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)
+}
diff --git a/packages/core/src/helpers/getMarkRange.ts b/packages/core/src/helpers/getMarkRange.ts
index ec34bda9..63c44c9e 100644
--- a/packages/core/src/helpers/getMarkRange.ts
+++ b/packages/core/src/helpers/getMarkRange.ts
@@ -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'
-export default function getMarkRange($pos: ResolvedPos, type: MarkType): Range | void {
+function findMarkInSet(
+ marks: ProseMirrorMark[],
+ type: MarkType,
+ attributes: Record = {},
+): ProseMirrorMark | undefined {
+ return marks.find(item => {
+ return item.type === type && objectIncludes(item.attrs, attributes)
+ })
+}
+
+function isMarkInSet(
+ marks: ProseMirrorMark[],
+ type: MarkType,
+ attributes: Record = {},
+): boolean {
+ return !!findMarkInSet(marks, type, attributes)
+}
+
+export default function getMarkRange(
+ $pos: ResolvedPos,
+ type: MarkType,
+ attributes: Record = {},
+): Range | void {
if (!$pos || !type) {
return
}
@@ -12,9 +35,9 @@ export default function getMarkRange($pos: ResolvedPos, type: MarkType): Range |
return
}
- const link = start.node.marks.find(mark => mark.type === type)
+ const mark = findMarkInSet(start.node.marks, type, attributes)
- if (!link) {
+ if (!mark) {
return
}
@@ -23,12 +46,17 @@ export default function getMarkRange($pos: ResolvedPos, type: MarkType): Range |
let endIndex = startIndex + 1
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
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
endIndex += 1
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index f5d98d4a..728cf8ff 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -22,6 +22,7 @@ export { default as generateHTML } from './helpers/generateHTML'
export { default as generateJSON } from './helpers/generateJSON'
export { default as getSchema } from './helpers/getSchema'
export { default as getHTMLFromFragment } from './helpers/getHTMLFromFragment'
+export { default as getDebugJSON } from './helpers/getDebugJSON'
export { default as getAttributes } from './helpers/getAttributes'
export { default as getMarkAttributes } from './helpers/getMarkAttributes'
export { default as getMarkRange } from './helpers/getMarkRange'
diff --git a/tests/cypress/integration/core/extendMarkRange.spec.ts b/tests/cypress/integration/core/extendMarkRange.spec.ts
new file mode 100644
index 00000000..6cc2330b
--- /dev/null
+++ b/tests/cypress/integration/core/extendMarkRange.spec.ts
@@ -0,0 +1,301 @@
+///
+
+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)
+ })
+})