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) + }) +})