@prosekit/core 0.8.7 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/{editor-M9OimMiI.d.ts → editor-BULC1zqX.d.ts} +31 -71
  2. package/dist/editor-BULC1zqX.d.ts.map +1 -0
  3. package/dist/{editor-B0L9BgMi.js → editor-g-Rqn-ZE.js} +119 -136
  4. package/dist/editor-g-Rqn-ZE.js.map +1 -0
  5. package/dist/prosekit-core-test.d.ts +1 -2
  6. package/dist/prosekit-core-test.d.ts.map +1 -1
  7. package/dist/prosekit-core-test.js +1 -1
  8. package/dist/prosekit-core-test.js.map +1 -1
  9. package/dist/prosekit-core.d.ts +182 -202
  10. package/dist/prosekit-core.d.ts.map +1 -1
  11. package/dist/prosekit-core.js +543 -549
  12. package/dist/prosekit-core.js.map +1 -1
  13. package/package.json +9 -12
  14. package/src/commands/add-mark.ts +1 -4
  15. package/src/commands/expand-mark.ts +2 -9
  16. package/src/commands/insert-default-block.spec.ts +1 -5
  17. package/src/commands/insert-default-block.ts +1 -4
  18. package/src/commands/insert-node.ts +4 -8
  19. package/src/commands/remove-mark.ts +1 -4
  20. package/src/commands/remove-node.ts +2 -2
  21. package/src/commands/select-all.ts +3 -8
  22. package/src/commands/select-block.spec.ts +81 -0
  23. package/src/commands/select-block.ts +56 -0
  24. package/src/commands/set-block-type.ts +1 -4
  25. package/src/commands/set-node-attrs-between.spec.ts +221 -0
  26. package/src/commands/set-node-attrs-between.ts +77 -0
  27. package/src/commands/set-node-attrs.spec.ts +129 -0
  28. package/src/commands/set-node-attrs.ts +25 -26
  29. package/src/commands/toggle-mark.ts +1 -4
  30. package/src/commands/toggle-node.ts +2 -5
  31. package/src/commands/toggle-wrap.spec.ts +1 -5
  32. package/src/commands/toggle-wrap.ts +1 -4
  33. package/src/commands/unset-block-type.spec.ts +1 -5
  34. package/src/commands/unset-block-type.ts +2 -8
  35. package/src/commands/unset-mark.spec.ts +1 -5
  36. package/src/commands/wrap.ts +2 -10
  37. package/src/editor/action.spec.ts +2 -6
  38. package/src/editor/action.ts +2 -19
  39. package/src/editor/editor.spec.ts +2 -9
  40. package/src/editor/editor.ts +31 -77
  41. package/src/editor/union.spec.ts +1 -5
  42. package/src/editor/union.ts +1 -4
  43. package/src/extensions/clipboard-serializer.ts +4 -16
  44. package/src/extensions/command.ts +20 -48
  45. package/src/extensions/default-state.spec.ts +1 -9
  46. package/src/extensions/default-state.ts +6 -32
  47. package/src/extensions/events/dom-event.spec.ts +1 -6
  48. package/src/extensions/events/dom-event.ts +5 -20
  49. package/src/extensions/events/editor-event.ts +5 -17
  50. package/src/extensions/events/focus.spec.ts +1 -6
  51. package/src/extensions/events/plugin-view.ts +2 -9
  52. package/src/extensions/history.ts +3 -10
  53. package/src/extensions/keymap-base.spec.ts +89 -0
  54. package/src/extensions/keymap-base.ts +34 -13
  55. package/src/extensions/keymap.spec.ts +12 -22
  56. package/src/extensions/keymap.ts +16 -69
  57. package/src/extensions/mark-spec.spec.ts +5 -20
  58. package/src/extensions/mark-spec.ts +33 -41
  59. package/src/extensions/mark-view-effect.ts +3 -9
  60. package/src/extensions/mark-view.ts +2 -8
  61. package/src/extensions/node-spec.spec.ts +5 -21
  62. package/src/extensions/node-spec.ts +31 -33
  63. package/src/extensions/node-view-effect.ts +3 -9
  64. package/src/extensions/node-view.ts +2 -8
  65. package/src/extensions/plugin.spec.ts +3 -16
  66. package/src/extensions/plugin.ts +4 -14
  67. package/src/facets/base-extension.ts +1 -4
  68. package/src/facets/command.ts +10 -10
  69. package/src/facets/facet-extension.spec.ts +4 -15
  70. package/src/facets/facet-node.spec.ts +2 -9
  71. package/src/facets/facet-node.ts +4 -9
  72. package/src/facets/facet.spec.ts +1 -4
  73. package/src/facets/schema-spec.ts +2 -9
  74. package/src/facets/schema.ts +3 -12
  75. package/src/facets/state.spec.ts +8 -15
  76. package/src/facets/state.ts +5 -20
  77. package/src/facets/union-extension.ts +2 -8
  78. package/src/index.ts +42 -188
  79. package/src/test/index.ts +1 -4
  80. package/src/test/test-builder.ts +1 -4
  81. package/src/test/test-editor.spec.ts +1 -5
  82. package/src/test/test-editor.ts +5 -24
  83. package/src/testing/index.ts +18 -14
  84. package/src/types/extension-command.ts +0 -7
  85. package/src/types/extension.spec.ts +1 -4
  86. package/src/types/extension.ts +3 -29
  87. package/src/types/simplify-union.ts +1 -4
  88. package/src/utils/array-grouping.spec.ts +2 -15
  89. package/src/utils/array-grouping.ts +1 -14
  90. package/src/utils/array.ts +0 -4
  91. package/src/utils/attrs-match.ts +1 -5
  92. package/src/utils/clsx.spec.ts +1 -4
  93. package/src/utils/combine-event-handlers.spec.ts +1 -5
  94. package/src/utils/combine-event-handlers.ts +4 -6
  95. package/src/utils/default-block-at.ts +1 -4
  96. package/src/utils/editor-content.spec.ts +1 -4
  97. package/src/utils/editor-content.ts +7 -19
  98. package/src/utils/find-node.ts +65 -0
  99. package/src/utils/find-parent-node-of-type.ts +6 -12
  100. package/src/utils/find-parent-node.spec.ts +1 -5
  101. package/src/utils/find-parent-node.ts +1 -4
  102. package/src/utils/get-custom-selection.ts +1 -5
  103. package/src/utils/get-mark-type.ts +1 -4
  104. package/src/utils/get-node-type.ts +1 -4
  105. package/src/utils/get-node-types.ts +1 -4
  106. package/src/utils/includes-mark.ts +1 -5
  107. package/src/utils/is-at-block-start.ts +1 -4
  108. package/src/utils/is-mark-absent.spec.ts +1 -4
  109. package/src/utils/is-mark-absent.ts +1 -5
  110. package/src/utils/is-mark-active.ts +1 -4
  111. package/src/utils/is-node-active.spec.ts +109 -0
  112. package/src/utils/is-node-active.ts +17 -8
  113. package/src/utils/is-subset.spec.ts +1 -4
  114. package/src/utils/maybe-run.spec.ts +1 -5
  115. package/src/utils/merge-objects.spec.ts +1 -4
  116. package/src/utils/merge-objects.ts +2 -1
  117. package/src/utils/merge-specs.ts +1 -4
  118. package/src/utils/object-equal.spec.ts +1 -4
  119. package/src/utils/output-spec.test.ts +1 -5
  120. package/src/utils/output-spec.ts +12 -9
  121. package/src/utils/parse.spec.ts +2 -11
  122. package/src/utils/parse.ts +12 -24
  123. package/src/utils/remove-undefined-values.spec.ts +1 -4
  124. package/src/utils/set-selection-around.ts +1 -4
  125. package/src/utils/type-assertion.ts +2 -21
  126. package/src/utils/unicode.spec.ts +1 -4
  127. package/dist/editor-B0L9BgMi.js.map +0 -1
  128. package/dist/editor-M9OimMiI.d.ts.map +0 -1
  129. package/src/extensions/doc.ts +0 -31
  130. package/src/extensions/paragraph.ts +0 -61
  131. package/src/extensions/text.ts +0 -34
  132. package/src/types/base-node-view-options.ts +0 -33
  133. package/src/types/object-entries.ts +0 -13
  134. package/src/utils/collect-children.ts +0 -21
  135. package/src/utils/collect-nodes.ts +0 -37
  136. package/src/utils/deep-equals.spec.ts +0 -26
  137. package/src/utils/deep-equals.ts +0 -29
  138. package/src/utils/get-id.spec.ts +0 -14
  139. package/src/utils/get-id.ts +0 -13
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@prosekit/core",
3
3
  "type": "module",
4
- "version": "0.8.7",
4
+ "version": "0.10.0",
5
5
  "private": false,
6
6
  "description": "Core features for ProseKit",
7
7
  "author": {
@@ -40,24 +40,21 @@
40
40
  "src"
41
41
  ],
42
42
  "dependencies": {
43
- "@ocavue/utils": "^0.8.1",
43
+ "@ocavue/utils": "^1.4.0",
44
44
  "clsx": "^2.1.1",
45
- "just-clone": "^6.2.0",
46
- "just-map-values": "^3.2.0",
47
45
  "orderedmap": "^2.1.1",
48
46
  "prosemirror-splittable": "^0.1.1",
49
- "type-fest": "^5.2.0",
50
- "@prosekit/pm": "^0.1.14"
47
+ "type-fest": "^5.4.3",
48
+ "@prosekit/pm": "^0.1.15"
51
49
  },
52
50
  "devDependencies": {
53
- "@types/diffable-html": "^5.0.2",
54
- "diffable-html": "^6.0.1",
55
- "tsdown": "^0.16.5",
51
+ "diffable-html-snapshot": "^0.2.0",
52
+ "tsdown": "^0.20.1",
56
53
  "typescript": "~5.9.3",
57
- "vitest": "^4.0.10",
54
+ "vitest": "^4.0.18",
58
55
  "vitest-browser-commands": "^0.2.0",
59
- "@prosekit/config-tsdown": "0.0.0",
60
- "@prosekit/config-vitest": "0.0.0"
56
+ "@prosekit/config-vitest": "0.0.0",
57
+ "@prosekit/config-tsdown": "0.0.0"
61
58
  },
62
59
  "publishConfig": {
63
60
  "dev": {}
@@ -1,7 +1,4 @@
1
- import type {
2
- Attrs,
3
- MarkType,
4
- } from '@prosekit/pm/model'
1
+ import type { Attrs, MarkType } from '@prosekit/pm/model'
5
2
  import type { Command } from '@prosekit/pm/state'
6
3
 
7
4
  import type { CommandCreator } from '../types/extension-command'
@@ -1,12 +1,5 @@
1
- import type {
2
- Mark,
3
- MarkType,
4
- ResolvedPos,
5
- } from '@prosekit/pm/model'
6
- import {
7
- TextSelection,
8
- type Command,
9
- } from '@prosekit/pm/state'
1
+ import type { Mark, MarkType, ResolvedPos } from '@prosekit/pm/model'
2
+ import { TextSelection, type Command } from '@prosekit/pm/state'
10
3
 
11
4
  import { getMarkType } from '../utils/get-mark-type'
12
5
 
@@ -1,8 +1,4 @@
1
- import {
2
- describe,
3
- expect,
4
- it,
5
- } from 'vitest'
1
+ import { describe, expect, it } from 'vitest'
6
2
 
7
3
  import { setupTest } from '../testing'
8
4
  import { inputText } from '../testing/keyboard'
@@ -1,7 +1,4 @@
1
- import {
2
- TextSelection,
3
- type Command,
4
- } from '@prosekit/pm/state'
1
+ import { TextSelection, type Command } from '@prosekit/pm/state'
5
2
 
6
3
  import { defaultBlockAt } from '../utils/default-block-at'
7
4
 
@@ -1,10 +1,6 @@
1
- import type {
2
- Attrs,
3
- NodeType,
4
- ProseMirrorNode,
5
- } from '@prosekit/pm/model'
1
+ import { Fragment, Slice, type Attrs, type NodeType, type ProseMirrorNode } from '@prosekit/pm/model'
6
2
  import type { Command } from '@prosekit/pm/state'
7
- import { insertPoint } from '@prosekit/pm/transform'
3
+ import { dropPoint } from '@prosekit/pm/transform'
8
4
 
9
5
  import { assert } from '../utils/assert'
10
6
  import { getNodeType } from '../utils/get-node-type'
@@ -52,10 +48,10 @@ function insertNode(options: InsertNodeOptions): Command {
52
48
 
53
49
  assert(node, 'You must provide either a node or a type')
54
50
 
55
- const insertPos = insertPoint(
51
+ const insertPos = dropPoint(
56
52
  state.doc,
57
53
  options.pos ?? state.selection.anchor,
58
- node.type,
54
+ new Slice(Fragment.from([node]), 0, 0),
59
55
  )
60
56
  if (insertPos == null) return false
61
57
 
@@ -1,7 +1,4 @@
1
- import type {
2
- Attrs,
3
- MarkType,
4
- } from '@prosekit/pm/model'
1
+ import type { Attrs, MarkType } from '@prosekit/pm/model'
5
2
  import type { Command } from '@prosekit/pm/state'
6
3
 
7
4
  import type { CommandCreator } from '../types/extension-command'
@@ -14,8 +14,8 @@ export interface RemoveNodeOptions {
14
14
  type: string | NodeType
15
15
 
16
16
  /**
17
- * The document position to start searching node. By default it will be the
18
- * anchor position of current selection.
17
+ * The document position to start searching for the node. By default, it will
18
+ * use the anchor position of current selection.
19
19
  */
20
20
  pos?: number
21
21
  }
@@ -1,7 +1,5 @@
1
- import {
2
- AllSelection,
3
- type Command,
4
- } from '@prosekit/pm/state'
1
+ import { selectAll as selectAllCommand } from '@prosekit/pm/commands'
2
+ import type { Command } from '@prosekit/pm/state'
5
3
 
6
4
  /**
7
5
  * Returns a command that selects the whole document.
@@ -9,8 +7,5 @@ import {
9
7
  * @public
10
8
  */
11
9
  export function selectAll(): Command {
12
- return (state, dispatch) => {
13
- dispatch?.(state.tr.setSelection(new AllSelection(state.doc)))
14
- return true
15
- }
10
+ return selectAllCommand
16
11
  }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { setupTest } from '../testing'
4
+
5
+ import { selectBlock } from './select-block'
6
+
7
+ describe('selectBlock', () => {
8
+ it('should expand the text selection to cover the start of the paragraph', () => {
9
+ const { editor, n, getSelectionString } = setup()
10
+ editor.set(n.doc(n.paragraph('Hello <a>world<b>')))
11
+ expect(getSelectionString()).toMatchInlineSnapshot(`"<paragraph("world")>"`)
12
+ editor.exec(selectBlock())
13
+ expect(getSelectionString()).toMatchInlineSnapshot(`"<paragraph("Hello world")>"`)
14
+ })
15
+
16
+ it('should expand the text selection to cover the end of the paragraph', () => {
17
+ const { editor, n, getSelectionString } = setup()
18
+ editor.set(n.doc(n.paragraph('<a>Hello<b> world')))
19
+ expect(getSelectionString()).toMatchInlineSnapshot(`"<paragraph("Hello")>"`)
20
+ editor.exec(selectBlock())
21
+ expect(getSelectionString()).toMatchInlineSnapshot(`"<paragraph("Hello world")>"`)
22
+ })
23
+
24
+ it('should expand the text selection to include other marks', () => {
25
+ const { editor, n, m, getSelectionString } = setup()
26
+ editor.set(n.doc(n.paragraph(
27
+ m.bold('Bold'),
28
+ ' ',
29
+ m.italic('Italic<a>'),
30
+ ' ',
31
+ m.bold('Bold'),
32
+ )))
33
+ expect(getSelectionString()).toMatchInlineSnapshot(`"<>"`)
34
+ editor.exec(selectBlock())
35
+ expect(getSelectionString()).toMatchInlineSnapshot(`"<paragraph(bold("Bold"), " ", italic("Italic"), " ", bold("Bold"))>"`)
36
+ })
37
+
38
+ it('should expand the text selection to include multiple blocks', () => {
39
+ const { editor, n, getSelectionString } = setup()
40
+ editor.set(n.doc(
41
+ n.paragraph('Hello<a>'),
42
+ n.paragraph('World'),
43
+ n.paragraph('<b>!'),
44
+ ))
45
+ expect(getSelectionString()).toMatchInlineSnapshot(`"<paragraph, paragraph("World"), paragraph>"`)
46
+ editor.exec(selectBlock())
47
+ expect(getSelectionString()).toMatchInlineSnapshot(`"<paragraph("Hello"), paragraph("World"), paragraph("!")>"`)
48
+ })
49
+
50
+ it('is able to expand the text selection pointing to different depths', () => {
51
+ const { editor, n, getSelectionString } = setup()
52
+ editor.set(n.doc(
53
+ n.paragraph('Hello<a>'),
54
+ n.blockquote([
55
+ n.paragraph('Item 1'),
56
+ n.blockquote([
57
+ n.paragraph('Sub<b> item 1.1'),
58
+ ]),
59
+ ]),
60
+ n.blockquote([
61
+ n.paragraph('Item 2'),
62
+ ]),
63
+ ))
64
+ expect(getSelectionString()).toMatchInlineSnapshot(`"<paragraph, blockquote(paragraph("Item 1"), blockquote(paragraph("Sub")))>"`)
65
+ editor.exec(selectBlock())
66
+ expect(getSelectionString()).toMatchInlineSnapshot(
67
+ `"<paragraph("Hello"), blockquote(paragraph("Item 1"), blockquote(paragraph("Sub item 1.1")))>"`,
68
+ )
69
+ })
70
+ })
71
+
72
+ function setup() {
73
+ const { editor, n, m } = setupTest()
74
+
75
+ const getSelectionString = () => {
76
+ const fragment = editor.state.selection.content().content
77
+ return fragment.toString()
78
+ }
79
+
80
+ return { editor, n, m, getSelectionString }
81
+ }
@@ -0,0 +1,56 @@
1
+ import { TextSelection, type Command } from '@prosekit/pm/state'
2
+
3
+ import { isTextSelection } from '../utils/type-assertion'
4
+
5
+ // Based on https://github.com/ProseMirror/prosemirror-commands/blob/1.7.1/src/commands.ts#L507-L521
6
+ function getTextblockEndpoint(selection: TextSelection, side: number): number | undefined {
7
+ const $pos = side < 0 ? selection.$from : selection.$to
8
+ let depth = $pos.depth
9
+ while ($pos.node(depth).isInline) {
10
+ if (!depth) {
11
+ return
12
+ }
13
+ depth--
14
+ }
15
+ if (!$pos.node(depth).isTextblock) {
16
+ return
17
+ }
18
+ return side < 0 ? $pos.start(depth) : $pos.end(depth)
19
+ }
20
+
21
+ /**
22
+ * @internal
23
+ */
24
+ export const selectBlockCommand: Command = (state, dispatch) => {
25
+ const { selection } = state
26
+ if (!isTextSelection(selection)) {
27
+ return false
28
+ }
29
+
30
+ const expectedFrom = getTextblockEndpoint(selection, -1)
31
+ const expectedTo = getTextblockEndpoint(selection, 1)
32
+ if (expectedFrom == null || expectedTo == null) {
33
+ return false
34
+ }
35
+
36
+ if (selection.from <= expectedFrom && selection.to >= expectedTo) {
37
+ return false
38
+ }
39
+
40
+ if (dispatch) {
41
+ const newSelection = TextSelection.create(state.doc, expectedFrom, expectedTo)
42
+ dispatch(state.tr.setSelection(newSelection))
43
+ }
44
+ return true
45
+ }
46
+
47
+ /**
48
+ * Returns a command to expand the text selection to cover the current block
49
+ * node. If the text selection spans multiple blocks, it will select all
50
+ * blocks in the selection.
51
+ *
52
+ * @public
53
+ */
54
+ export function selectBlock(): Command {
55
+ return selectBlockCommand
56
+ }
@@ -1,7 +1,4 @@
1
- import type {
2
- Attrs,
3
- NodeType,
4
- } from '@prosekit/pm/model'
1
+ import type { Attrs, NodeType } from '@prosekit/pm/model'
5
2
  import type { Command } from '@prosekit/pm/state'
6
3
 
7
4
  import { getCustomSelection } from '../utils/get-custom-selection'
@@ -0,0 +1,221 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { setupTest } from '../testing'
4
+
5
+ import { setNodeAttrsBetween } from './set-node-attrs-between'
6
+
7
+ describe('setNodeAttrsBetween', () => {
8
+ it('should set attributes on multiple nodes in selection range', () => {
9
+ const { editor, n } = setupTest()
10
+
11
+ editor.set(
12
+ n.doc(
13
+ n.codeBlock('<a>first block'),
14
+ n.codeBlock('second block<b>'),
15
+ n.codeBlock('third block'),
16
+ ),
17
+ )
18
+
19
+ const command = setNodeAttrsBetween({
20
+ type: 'codeBlock',
21
+ attrs: { language: 'rust' },
22
+ })
23
+
24
+ expect(editor.exec(command)).toBe(true)
25
+
26
+ expect(editor.state.doc.child(0).attrs.language).toBe('rust')
27
+ expect(editor.state.doc.child(1).attrs.language).toBe('rust')
28
+ expect(editor.state.doc.child(2).attrs.language).toBe('')
29
+ })
30
+
31
+ it('should set attributes with explicit from/to positions', () => {
32
+ const { editor, n } = setupTest()
33
+
34
+ editor.set(
35
+ n.doc(
36
+ /*0*/
37
+ n.codeBlock(/*1*/ 'A' /*2*/),
38
+ /*3*/
39
+ n.codeBlock(/*4*/ 'B' /*5*/),
40
+ /*6*/
41
+ n.codeBlock(/*7*/ 'C' /*8*/),
42
+ /*9*/
43
+ ),
44
+ )
45
+
46
+ const command = setNodeAttrsBetween({
47
+ type: 'codeBlock',
48
+ attrs: { language: 'go' },
49
+ from: 5,
50
+ to: 7,
51
+ })
52
+
53
+ expect(editor.exec(command)).toBe(true)
54
+
55
+ expect(editor.state.doc.child(0).attrs.language).toBe('')
56
+ expect(editor.state.doc.child(1).attrs.language).toBe('go')
57
+ expect(editor.state.doc.child(2).attrs.language).toBe('go')
58
+ })
59
+
60
+ it('should return false when no matching nodes in range', () => {
61
+ const { editor, n } = setupTest()
62
+
63
+ editor.set(
64
+ n.doc(
65
+ n.paragraph('<a>first paragraph'),
66
+ n.paragraph('second paragraph<b>'),
67
+ ),
68
+ )
69
+
70
+ const command = setNodeAttrsBetween({
71
+ type: 'codeBlock',
72
+ attrs: { language: 'typescript' },
73
+ })
74
+
75
+ expect(editor.exec(command)).toBe(false)
76
+ })
77
+
78
+ it('should return false when from > to', () => {
79
+ const { editor, n } = setupTest()
80
+
81
+ editor.set(n.doc(n.codeBlock('code')))
82
+
83
+ const command = setNodeAttrsBetween({
84
+ type: 'codeBlock',
85
+ attrs: { language: 'typescript' },
86
+ from: 10,
87
+ to: 5,
88
+ })
89
+
90
+ expect(editor.exec(command)).toBe(false)
91
+ })
92
+
93
+ it('should handle empty selection (from === to)', () => {
94
+ const { editor, n } = setupTest()
95
+
96
+ editor.set(n.doc(n.codeBlock('<a>code')))
97
+
98
+ const command = setNodeAttrsBetween({
99
+ type: 'codeBlock',
100
+ attrs: { language: 'typescript' },
101
+ })
102
+
103
+ expect(editor.exec(command)).toBe(true)
104
+ expect(editor.state.doc.firstChild?.attrs.language).toBe('typescript')
105
+ })
106
+
107
+ it('should handle partial node overlap', () => {
108
+ const { editor, n } = setupTest()
109
+
110
+ editor.set(
111
+ n.doc(
112
+ n.codeBlock('first bl<a>ock'),
113
+ n.codeBlock('second block'),
114
+ n.codeBlock('third bl<b>ock'),
115
+ ),
116
+ )
117
+
118
+ const command = setNodeAttrsBetween({
119
+ type: 'codeBlock',
120
+ attrs: { language: 'ruby' },
121
+ })
122
+
123
+ expect(editor.exec(command)).toBe(true)
124
+
125
+ expect(editor.state.doc.child(0).attrs.language).toBe('ruby')
126
+ expect(editor.state.doc.child(1).attrs.language).toBe('ruby')
127
+ expect(editor.state.doc.child(2).attrs.language).toBe('ruby')
128
+ })
129
+
130
+ it('should update only matching node types in mixed content', () => {
131
+ const { editor, n } = setupTest()
132
+
133
+ editor.set(
134
+ n.doc(
135
+ n.paragraph('<a>paragraph 1'),
136
+ n.codeBlock('code block 1'),
137
+ n.paragraph('paragraph 2'),
138
+ n.codeBlock('code block 2'),
139
+ n.paragraph('paragraph 3<b>'),
140
+ ),
141
+ )
142
+
143
+ const command = setNodeAttrsBetween({
144
+ type: 'codeBlock',
145
+ attrs: { language: 'java' },
146
+ })
147
+
148
+ expect(editor.exec(command)).toBe(true)
149
+
150
+ expect(editor.state.doc.child(1).attrs.language).toBe('java')
151
+ expect(editor.state.doc.child(3).attrs.language).toBe('java')
152
+ })
153
+
154
+ it('should handle nested nodes', () => {
155
+ const { editor, n } = setupTest()
156
+
157
+ const doc1 = n.doc(
158
+ n.blockquote(
159
+ { variant: 'variant-0' },
160
+ n.paragraph('paragraph-0'),
161
+ ),
162
+ n.blockquote(
163
+ { variant: 'variant-1' },
164
+ n.paragraph('paragraph-1'),
165
+ n.blockquote(
166
+ { variant: 'variant-1.1' },
167
+ n.paragraph('paragraph-1.1<a>'),
168
+ ),
169
+ ),
170
+ n.blockquote(
171
+ { variant: 'variant-2' },
172
+ n.paragraph('paragraph-2'),
173
+ ),
174
+ n.blockquote(
175
+ { variant: 'variant-3' },
176
+ n.paragraph('paragraph-3<b>'),
177
+ n.blockquote(
178
+ { variant: 'variant-3.1' },
179
+ n.paragraph('paragraph-3.1'),
180
+ ),
181
+ ),
182
+ )
183
+
184
+ const doc2 = n.doc(
185
+ n.blockquote(
186
+ { variant: 'variant-0' },
187
+ n.paragraph('paragraph-0'),
188
+ ),
189
+ n.blockquote(
190
+ { variant: 'variant-X' },
191
+ n.paragraph('paragraph-1'),
192
+ n.blockquote(
193
+ { variant: 'variant-X' },
194
+ n.paragraph('paragraph-1.1'),
195
+ ),
196
+ ),
197
+ n.blockquote(
198
+ { variant: 'variant-X' },
199
+ n.paragraph('paragraph-2'),
200
+ ),
201
+ n.blockquote(
202
+ { variant: 'variant-X' },
203
+ n.paragraph('paragraph-3'),
204
+ n.blockquote(
205
+ { variant: 'variant-3.1' },
206
+ n.paragraph('paragraph-3.1'),
207
+ ),
208
+ ),
209
+ )
210
+
211
+ editor.set(doc1)
212
+
213
+ const command = setNodeAttrsBetween({
214
+ type: 'blockquote',
215
+ attrs: { variant: 'variant-X' },
216
+ })
217
+
218
+ expect(editor.exec(command)).toBe(true)
219
+ expect(editor.getDocJSON()).toEqual(doc2.toJSON())
220
+ })
221
+ })
@@ -0,0 +1,77 @@
1
+ import type { Attrs, NodeType } from '@prosekit/pm/model'
2
+ import type { Command } from '@prosekit/pm/state'
3
+
4
+ import { getNodeTypes } from '../utils/get-node-types'
5
+
6
+ /**
7
+ * @public
8
+ */
9
+ export interface SetNodeAttrsBetweenOptions {
10
+ /**
11
+ * The type of node to set the attributes of.
12
+ */
13
+ type: string | NodeType | string[] | NodeType[]
14
+
15
+ /**
16
+ * The attributes to set.
17
+ */
18
+ attrs: Attrs
19
+
20
+ /**
21
+ * The position to start searching for nodes. By default, the selection from position will be used.
22
+ */
23
+ from?: number
24
+
25
+ /**
26
+ * The position to end searching for nodes. By default, the selection to position will be used.
27
+ */
28
+ to?: number
29
+ }
30
+
31
+ /**
32
+ * Returns a command that sets the attributes of all matching nodes between the
33
+ * `from` and `to` positions.
34
+ *
35
+ * @param options
36
+ *
37
+ * @public
38
+ */
39
+ export function setNodeAttrsBetween(options: SetNodeAttrsBetweenOptions): Command {
40
+ return (state, dispatch) => {
41
+ const from = options.from ?? state.selection.from
42
+ const to = options.to ?? state.selection.to
43
+ if (from > to) {
44
+ return false
45
+ }
46
+
47
+ const nodeTypes = getNodeTypes(state.schema, options.type)
48
+ const positions: number[] = []
49
+ let found = false
50
+
51
+ state.doc.nodesBetween(from, to, (node, pos) => {
52
+ if (nodeTypes.includes(node.type)) {
53
+ positions.push(pos)
54
+ found = true
55
+ }
56
+ if (!dispatch && found) {
57
+ // Early return to prevent further iteration
58
+ return false
59
+ }
60
+ })
61
+
62
+ if (!found) {
63
+ return false
64
+ }
65
+
66
+ if (dispatch) {
67
+ const { tr } = state
68
+ for (const [key, value] of Object.entries(options.attrs)) {
69
+ for (const pos of positions) {
70
+ tr.setNodeAttribute(pos, key, value)
71
+ }
72
+ }
73
+ dispatch(tr)
74
+ }
75
+ return true
76
+ }
77
+ }