@prosekit/core 0.9.0 → 0.11.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 (143) hide show
  1. package/dist/{editor-4lgGc3CY.d.ts → editor.d.ts} +58 -18
  2. package/dist/editor.d.ts.map +1 -0
  3. package/dist/{editor-DGNUXn-u.js → editor.js} +40 -81
  4. package/dist/editor.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 +2 -4
  8. package/dist/prosekit-core-test.js.map +1 -1
  9. package/dist/prosekit-core.d.ts +148 -68
  10. package/dist/prosekit-core.d.ts.map +1 -1
  11. package/dist/prosekit-core.js +184 -138
  12. package/dist/prosekit-core.js.map +1 -1
  13. package/package.json +9 -9
  14. package/src/commands/add-mark.ts +3 -6
  15. package/src/commands/expand-mark.ts +4 -11
  16. package/src/commands/insert-default-block.spec.ts +4 -8
  17. package/src/commands/insert-default-block.ts +2 -5
  18. package/src/commands/insert-node.ts +7 -11
  19. package/src/commands/remove-mark.ts +3 -6
  20. package/src/commands/remove-node.ts +4 -4
  21. package/src/commands/select-block.spec.ts +6 -8
  22. package/src/commands/select-block.ts +2 -5
  23. package/src/commands/set-block-type.ts +3 -6
  24. package/src/commands/set-node-attrs-between.spec.ts +221 -0
  25. package/src/commands/set-node-attrs-between.ts +77 -0
  26. package/src/commands/set-node-attrs.spec.ts +129 -0
  27. package/src/commands/set-node-attrs.ts +26 -27
  28. package/src/commands/toggle-mark.ts +3 -6
  29. package/src/commands/toggle-node.ts +4 -7
  30. package/src/commands/toggle-wrap.spec.ts +2 -6
  31. package/src/commands/toggle-wrap.ts +3 -6
  32. package/src/commands/unset-block-type.spec.ts +2 -6
  33. package/src/commands/unset-block-type.ts +3 -9
  34. package/src/commands/unset-mark.spec.ts +2 -6
  35. package/src/commands/unset-mark.ts +1 -1
  36. package/src/commands/wrap.ts +2 -5
  37. package/src/editor/action.spec.ts +5 -9
  38. package/src/editor/action.ts +7 -14
  39. package/src/editor/editor.spec.ts +8 -15
  40. package/src/editor/editor.ts +18 -52
  41. package/src/editor/union.spec.ts +8 -12
  42. package/src/editor/union.ts +4 -7
  43. package/src/editor/with-priority.ts +3 -3
  44. package/src/error.ts +8 -1
  45. package/src/extensions/clipboard-serializer.ts +22 -26
  46. package/src/extensions/command.ts +22 -54
  47. package/src/extensions/default-state.spec.ts +4 -8
  48. package/src/extensions/default-state.ts +6 -12
  49. package/src/extensions/events/doc-change.ts +2 -2
  50. package/src/extensions/events/dom-event.spec.ts +4 -9
  51. package/src/extensions/events/dom-event.ts +9 -21
  52. package/src/extensions/events/editor-event.ts +8 -20
  53. package/src/extensions/events/focus.spec.ts +7 -12
  54. package/src/extensions/events/focus.ts +2 -2
  55. package/src/extensions/events/plugin-view.ts +5 -12
  56. package/src/extensions/history.ts +7 -14
  57. package/src/extensions/keymap-base.spec.ts +6 -15
  58. package/src/extensions/keymap-base.ts +6 -9
  59. package/src/extensions/keymap.spec.ts +10 -24
  60. package/src/extensions/keymap.ts +5 -15
  61. package/src/extensions/mark-spec.spec.ts +6 -21
  62. package/src/extensions/mark-spec.ts +10 -21
  63. package/src/extensions/mark-view-effect.ts +6 -12
  64. package/src/extensions/mark-view.ts +5 -11
  65. package/src/extensions/node-spec.spec.ts +10 -26
  66. package/src/extensions/node-spec.ts +10 -21
  67. package/src/extensions/node-view-effect.ts +6 -12
  68. package/src/extensions/node-view.ts +5 -11
  69. package/src/extensions/plugin.spec.ts +9 -22
  70. package/src/extensions/plugin.ts +6 -15
  71. package/src/facets/base-extension.ts +7 -10
  72. package/src/facets/command.ts +3 -9
  73. package/src/facets/facet-extension.spec.ts +10 -21
  74. package/src/facets/facet-extension.ts +12 -8
  75. package/src/facets/facet-node.spec.ts +4 -11
  76. package/src/facets/facet-node.ts +27 -22
  77. package/src/facets/facet.spec.ts +2 -5
  78. package/src/facets/facet.ts +14 -7
  79. package/src/facets/root.ts +2 -2
  80. package/src/facets/schema-spec.ts +3 -10
  81. package/src/facets/schema.ts +4 -13
  82. package/src/facets/state.spec.ts +8 -15
  83. package/src/facets/state.ts +5 -19
  84. package/src/facets/union-extension.ts +10 -13
  85. package/src/index.ts +74 -200
  86. package/src/test/index.ts +1 -4
  87. package/src/test/test-builder.ts +2 -5
  88. package/src/test/test-editor.spec.ts +2 -6
  89. package/src/test/test-editor.ts +7 -26
  90. package/src/testing/index.ts +26 -22
  91. package/src/types/extension-mark.ts +1 -1
  92. package/src/types/extension-node.ts +1 -1
  93. package/src/types/extension.spec.ts +2 -5
  94. package/src/types/extension.ts +8 -18
  95. package/src/types/pick-string-literal.spec.ts +2 -2
  96. package/src/types/pick-string-literal.ts +1 -1
  97. package/src/types/pick-sub-type.spec.ts +2 -2
  98. package/src/types/priority.ts +50 -7
  99. package/src/types/simplify-deeper.spec.ts +2 -2
  100. package/src/types/simplify-union.spec.ts +2 -2
  101. package/src/types/simplify-union.ts +1 -4
  102. package/src/utils/array-grouping.spec.ts +2 -5
  103. package/src/utils/assert.ts +1 -1
  104. package/src/utils/attrs-match.ts +1 -5
  105. package/src/utils/can-use-regex-lookbehind.ts +2 -8
  106. package/src/utils/clsx.spec.ts +2 -5
  107. package/src/utils/combine-event-handlers.spec.ts +2 -6
  108. package/src/utils/default-block-at.ts +1 -4
  109. package/src/utils/editor-content.spec.ts +3 -6
  110. package/src/utils/editor-content.ts +5 -17
  111. package/src/utils/find-node.ts +65 -0
  112. package/src/utils/find-parent-node-of-type.ts +6 -12
  113. package/src/utils/find-parent-node.spec.ts +3 -7
  114. package/src/utils/find-parent-node.ts +1 -4
  115. package/src/utils/get-custom-selection.ts +1 -5
  116. package/src/utils/get-dom-api.ts +1 -1
  117. package/src/utils/get-mark-type.ts +2 -5
  118. package/src/utils/get-node-type.ts +2 -5
  119. package/src/utils/get-node-types.ts +2 -5
  120. package/src/utils/includes-mark.ts +2 -6
  121. package/src/utils/is-at-block-start.ts +1 -4
  122. package/src/utils/is-mark-absent.spec.ts +3 -6
  123. package/src/utils/is-mark-absent.ts +2 -6
  124. package/src/utils/is-mark-active.ts +4 -7
  125. package/src/utils/is-node-active.spec.ts +109 -0
  126. package/src/utils/is-node-active.ts +19 -10
  127. package/src/utils/is-subset.spec.ts +2 -5
  128. package/src/utils/maybe-run.spec.ts +2 -6
  129. package/src/utils/merge-objects.spec.ts +2 -5
  130. package/src/utils/merge-objects.ts +3 -2
  131. package/src/utils/merge-specs.ts +2 -5
  132. package/src/utils/object-equal.spec.ts +2 -5
  133. package/src/utils/output-spec.test.ts +2 -6
  134. package/src/utils/output-spec.ts +2 -10
  135. package/src/utils/parse.spec.ts +6 -15
  136. package/src/utils/parse.ts +4 -16
  137. package/src/utils/remove-undefined-values.spec.ts +2 -5
  138. package/src/utils/set-selection-around.ts +1 -4
  139. package/src/utils/type-assertion.ts +2 -21
  140. package/src/utils/unicode.spec.ts +2 -5
  141. package/src/utils/with-skip-code-block.ts +1 -1
  142. package/dist/editor-4lgGc3CY.d.ts.map +0 -1
  143. package/dist/editor-DGNUXn-u.js.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@prosekit/core",
3
3
  "type": "module",
4
- "version": "0.9.0",
4
+ "version": "0.11.0",
5
5
  "private": false,
6
6
  "description": "Core features for ProseKit",
7
7
  "author": {
@@ -40,22 +40,22 @@
40
40
  "src"
41
41
  ],
42
42
  "dependencies": {
43
- "@ocavue/utils": "^1.2.0",
43
+ "@ocavue/utils": "^1.6.0",
44
44
  "clsx": "^2.1.1",
45
45
  "orderedmap": "^2.1.1",
46
46
  "prosemirror-splittable": "^0.1.1",
47
- "type-fest": "^5.3.1",
47
+ "type-fest": "^5.5.0",
48
48
  "@prosekit/pm": "^0.1.15"
49
49
  },
50
50
  "devDependencies": {
51
- "@types/diffable-html": "^5.0.2",
52
- "diffable-html": "^6.0.1",
53
- "tsdown": "^0.17.0",
51
+ "diffable-html-snapshot": "^0.2.0",
52
+ "tsdown": "^0.21.4",
54
53
  "typescript": "~5.9.3",
55
- "vitest": "^4.0.15",
54
+ "vitest": "^4.1.1",
56
55
  "vitest-browser-commands": "^0.2.0",
57
- "@prosekit/config-vitest": "0.0.0",
58
- "@prosekit/config-tsdown": "0.0.0"
56
+ "@prosekit/config-tsdown": "0.0.0",
57
+ "@prosekit/config-ts": "0.0.0",
58
+ "@prosekit/config-vitest": "0.0.0"
59
59
  },
60
60
  "publishConfig": {
61
61
  "dev": {}
@@ -1,11 +1,8 @@
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
- import type { CommandCreator } from '../types/extension-command'
8
- import { getMarkType } from '../utils/get-mark-type'
4
+ import type { CommandCreator } from '../types/extension-command.ts'
5
+ import { getMarkType } from '../utils/get-mark-type.ts'
9
6
 
10
7
  /**
11
8
  * @public
@@ -1,14 +1,7 @@
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'
10
-
11
- import { getMarkType } from '../utils/get-mark-type'
1
+ import type { Mark, MarkType, ResolvedPos } from '@prosekit/pm/model'
2
+ import { TextSelection, type Command } from '@prosekit/pm/state'
3
+
4
+ import { getMarkType } from '../utils/get-mark-type.ts'
12
5
 
13
6
  /**
14
7
  * @public
@@ -1,13 +1,9 @@
1
- import {
2
- describe,
3
- expect,
4
- it,
5
- } from 'vitest'
1
+ import { describe, expect, it } from 'vitest'
6
2
 
7
- import { setupTest } from '../testing'
8
- import { inputText } from '../testing/keyboard'
3
+ import { setupTest } from '../testing/index.ts'
4
+ import { inputText } from '../testing/keyboard.ts'
9
5
 
10
- import { insertDefaultBlock } from './insert-default-block'
6
+ import { insertDefaultBlock } from './insert-default-block.ts'
11
7
 
12
8
  describe('insertDefaultBlock', () => {
13
9
  const { editor, n } = setupTest()
@@ -1,9 +1,6 @@
1
- import {
2
- TextSelection,
3
- type Command,
4
- } from '@prosekit/pm/state'
1
+ import { TextSelection, type Command } from '@prosekit/pm/state'
5
2
 
6
- import { defaultBlockAt } from '../utils/default-block-at'
3
+ import { defaultBlockAt } from '../utils/default-block-at.ts'
7
4
 
8
5
  /**
9
6
  * @public
@@ -1,14 +1,10 @@
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
- import { assert } from '../utils/assert'
10
- import { getNodeType } from '../utils/get-node-type'
11
- import { setSelectionAround } from '../utils/set-selection-around'
5
+ import { assert } from '../utils/assert.ts'
6
+ import { getNodeType } from '../utils/get-node-type.ts'
7
+ import { setSelectionAround } from '../utils/set-selection-around.ts'
12
8
 
13
9
  /**
14
10
  * @public
@@ -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,11 +1,8 @@
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
- import type { CommandCreator } from '../types/extension-command'
8
- import { getMarkType } from '../utils/get-mark-type'
4
+ import type { CommandCreator } from '../types/extension-command.ts'
5
+ import { getMarkType } from '../utils/get-mark-type.ts'
9
6
 
10
7
  /**
11
8
  * @public
@@ -1,8 +1,8 @@
1
1
  import type { NodeType } from '@prosekit/pm/model'
2
2
  import type { Command } from '@prosekit/pm/state'
3
3
 
4
- import type { CommandCreator } from '../types/extension-command'
5
- import { findParentNodeOfType } from '../utils/find-parent-node-of-type'
4
+ import type { CommandCreator } from '../types/extension-command.ts'
5
+ import { findParentNodeOfType } from '../utils/find-parent-node-of-type.ts'
6
6
 
7
7
  /**
8
8
  * @public
@@ -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,12 +1,8 @@
1
- import {
2
- describe,
3
- expect,
4
- it,
5
- } from 'vitest'
1
+ import { describe, expect, it } from 'vitest'
6
2
 
7
- import { setupTest } from '../testing'
3
+ import { setupTest } from '../testing/index.ts'
8
4
 
9
- import { selectBlock } from './select-block'
5
+ import { selectBlock } from './select-block.ts'
10
6
 
11
7
  describe('selectBlock', () => {
12
8
  it('should expand the text selection to cover the start of the paragraph', () => {
@@ -67,7 +63,9 @@ describe('selectBlock', () => {
67
63
  ))
68
64
  expect(getSelectionString()).toMatchInlineSnapshot(`"<paragraph, blockquote(paragraph("Item 1"), blockquote(paragraph("Sub")))>"`)
69
65
  editor.exec(selectBlock())
70
- expect(getSelectionString()).toMatchInlineSnapshot(`"<paragraph("Hello"), blockquote(paragraph("Item 1"), blockquote(paragraph("Sub item 1.1")))>"`)
66
+ expect(getSelectionString()).toMatchInlineSnapshot(
67
+ `"<paragraph("Hello"), blockquote(paragraph("Item 1"), blockquote(paragraph("Sub item 1.1")))>"`,
68
+ )
71
69
  })
72
70
  })
73
71
 
@@ -1,9 +1,6 @@
1
- import {
2
- TextSelection,
3
- type Command,
4
- } from '@prosekit/pm/state'
1
+ import { TextSelection, type Command } from '@prosekit/pm/state'
5
2
 
6
- import { isTextSelection } from '../utils/type-assertion'
3
+ import { isTextSelection } from '../utils/type-assertion.ts'
7
4
 
8
5
  // Based on https://github.com/ProseMirror/prosemirror-commands/blob/1.7.1/src/commands.ts#L507-L521
9
6
  function getTextblockEndpoint(selection: TextSelection, side: number): number | undefined {
@@ -1,11 +1,8 @@
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
- import { getCustomSelection } from '../utils/get-custom-selection'
8
- import { getNodeType } from '../utils/get-node-type'
4
+ import { getCustomSelection } from '../utils/get-custom-selection.ts'
5
+ import { getNodeType } from '../utils/get-node-type.ts'
9
6
 
10
7
  /**
11
8
  * @public
@@ -0,0 +1,221 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { setupTest } from '../testing/index.ts'
4
+
5
+ import { setNodeAttrsBetween } from './set-node-attrs-between.ts'
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.ts'
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
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { setupTest } from '../testing/index.ts'
4
+
5
+ import { setNodeAttrs } from './set-node-attrs.ts'
6
+
7
+ describe('setNodeAttrs', () => {
8
+ it('should set attributes on a code block node', () => {
9
+ const { editor, n } = setupTest()
10
+
11
+ editor.set(n.doc(n.codeBlock('const x = 1')))
12
+
13
+ // Get the initial language attribute (should be empty string by default)
14
+ expect(editor.state.doc.firstChild?.attrs.language).toBe('')
15
+
16
+ // Set the language attribute
17
+ const command = setNodeAttrs({
18
+ type: 'codeBlock',
19
+ attrs: { language: 'typescript' },
20
+ })
21
+
22
+ expect(editor.exec(command)).toBe(true)
23
+ expect(editor.state.doc.firstChild?.attrs.language).toBe('typescript')
24
+ })
25
+
26
+ it('should set multiple attributes at once', () => {
27
+ const { editor, n } = setupTest()
28
+
29
+ editor.set(n.doc(n.codeBlock('const x = 1')))
30
+
31
+ // Verify initial state
32
+ expect(editor.state.doc.firstChild?.attrs).toMatchObject({
33
+ language: '',
34
+ lineNumbers: false,
35
+ })
36
+
37
+ // Set multiple attributes at once
38
+ const command = setNodeAttrs({
39
+ type: 'codeBlock',
40
+ attrs: { language: 'javascript', lineNumbers: true },
41
+ })
42
+
43
+ editor.exec(command)
44
+
45
+ // Verify both attributes were set
46
+ expect(editor.state.doc.firstChild?.attrs).toMatchObject({
47
+ language: 'javascript',
48
+ lineNumbers: true,
49
+ })
50
+ })
51
+
52
+ it('should return false when node type does not match', () => {
53
+ const { editor, n } = setupTest()
54
+
55
+ editor.set(n.doc(n.paragraph('Hello world')))
56
+
57
+ const command = setNodeAttrs({
58
+ type: 'codeBlock',
59
+ attrs: { language: 'typescript' },
60
+ })
61
+
62
+ // Should return false because paragraph is not a codeBlock
63
+ expect(editor.exec(command)).toBe(false)
64
+ })
65
+
66
+ it('should set attributes at a specific position', () => {
67
+ const { editor, n } = setupTest()
68
+
69
+ editor.set(
70
+ n.doc(
71
+ /*0*/
72
+ n.codeBlock(/*1*/ 'A' /*2*/),
73
+ /*3*/
74
+ n.codeBlock(/*4*/ 'B' /*5*/),
75
+ /*6*/
76
+ ),
77
+ )
78
+
79
+ // Set attribute on the second code block (position after first block)
80
+ const command = setNodeAttrs({
81
+ type: 'codeBlock',
82
+ attrs: { language: 'python' },
83
+ pos: 3, // Position of second code block
84
+ })
85
+
86
+ editor.exec(command)
87
+
88
+ // First block should still have default language
89
+ expect(editor.state.doc.child(0).attrs.language).toBe('')
90
+ // Second block should have the new language
91
+ expect(editor.state.doc.child(1).attrs.language).toBe('python')
92
+ })
93
+
94
+ it('should handle cursor inside a node', () => {
95
+ const { editor, n } = setupTest()
96
+
97
+ editor.set(n.doc(n.codeBlock('const<a> x = 1')))
98
+
99
+ const command = setNodeAttrs({
100
+ type: 'codeBlock',
101
+ attrs: { language: 'typescript' },
102
+ })
103
+
104
+ editor.exec(command)
105
+
106
+ expect(editor.state.doc.firstChild?.attrs.language).toBe('typescript')
107
+ })
108
+
109
+ it('should set attrs on wrapping node containing selection', () => {
110
+ const { editor, n } = setupTest()
111
+
112
+ editor.set(
113
+ n.doc(
114
+ n.blockquote(
115
+ n.paragraph('Hello<a> world<b>'),
116
+ ),
117
+ ),
118
+ )
119
+
120
+ const command = setNodeAttrs({
121
+ type: 'blockquote',
122
+ attrs: { variant: 'fancy' },
123
+ })
124
+
125
+ // Should find the blockquote wrapping the selection
126
+ expect(editor.exec(command)).toBe(true)
127
+ expect(editor.state.doc.firstChild?.attrs.variant).toBe('fancy')
128
+ })
129
+ })