@prosekit/core 0.9.0 → 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 (123) hide show
  1. package/dist/{editor-4lgGc3CY.d.ts → editor-BULC1zqX.d.ts} +12 -12
  2. package/dist/editor-BULC1zqX.d.ts.map +1 -0
  3. package/dist/{editor-DGNUXn-u.js → editor-g-Rqn-ZE.js} +30 -30
  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 +103 -24
  10. package/dist/prosekit-core.d.ts.map +1 -1
  11. package/dist/prosekit-core.js +116 -35
  12. package/dist/prosekit-core.js.map +1 -1
  13. package/package.json +6 -7
  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-block.spec.ts +4 -6
  22. package/src/commands/select-block.ts +1 -4
  23. package/src/commands/set-block-type.ts +1 -4
  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 +25 -26
  28. package/src/commands/toggle-mark.ts +1 -4
  29. package/src/commands/toggle-node.ts +2 -5
  30. package/src/commands/toggle-wrap.spec.ts +1 -5
  31. package/src/commands/toggle-wrap.ts +1 -4
  32. package/src/commands/unset-block-type.spec.ts +1 -5
  33. package/src/commands/unset-block-type.ts +2 -8
  34. package/src/commands/unset-mark.spec.ts +1 -5
  35. package/src/commands/wrap.ts +1 -4
  36. package/src/editor/action.spec.ts +2 -6
  37. package/src/editor/action.ts +1 -8
  38. package/src/editor/editor.spec.ts +2 -9
  39. package/src/editor/editor.ts +11 -45
  40. package/src/editor/union.spec.ts +1 -5
  41. package/src/editor/union.ts +1 -4
  42. package/src/extensions/clipboard-serializer.ts +4 -16
  43. package/src/extensions/command.ts +16 -48
  44. package/src/extensions/default-state.spec.ts +1 -5
  45. package/src/extensions/default-state.ts +2 -8
  46. package/src/extensions/events/dom-event.spec.ts +1 -6
  47. package/src/extensions/events/dom-event.ts +4 -16
  48. package/src/extensions/events/editor-event.ts +4 -16
  49. package/src/extensions/events/focus.spec.ts +1 -6
  50. package/src/extensions/events/plugin-view.ts +2 -9
  51. package/src/extensions/history.ts +2 -9
  52. package/src/extensions/keymap-base.spec.ts +2 -11
  53. package/src/extensions/keymap-base.ts +1 -4
  54. package/src/extensions/keymap.spec.ts +6 -20
  55. package/src/extensions/keymap.ts +3 -13
  56. package/src/extensions/mark-spec.spec.ts +5 -20
  57. package/src/extensions/mark-spec.ts +4 -15
  58. package/src/extensions/mark-view-effect.ts +3 -9
  59. package/src/extensions/mark-view.ts +2 -8
  60. package/src/extensions/node-spec.spec.ts +5 -21
  61. package/src/extensions/node-spec.ts +4 -15
  62. package/src/extensions/node-view-effect.ts +3 -9
  63. package/src/extensions/node-view.ts +2 -8
  64. package/src/extensions/plugin.spec.ts +3 -16
  65. package/src/extensions/plugin.ts +3 -12
  66. package/src/facets/base-extension.ts +1 -4
  67. package/src/facets/command.ts +2 -8
  68. package/src/facets/facet-extension.spec.ts +4 -15
  69. package/src/facets/facet-node.spec.ts +2 -9
  70. package/src/facets/facet-node.ts +4 -9
  71. package/src/facets/facet.spec.ts +1 -4
  72. package/src/facets/schema-spec.ts +2 -9
  73. package/src/facets/schema.ts +3 -12
  74. package/src/facets/state.spec.ts +2 -9
  75. package/src/facets/state.ts +4 -18
  76. package/src/facets/union-extension.ts +2 -8
  77. package/src/index.ts +40 -166
  78. package/src/test/index.ts +1 -4
  79. package/src/test/test-builder.ts +1 -4
  80. package/src/test/test-editor.spec.ts +1 -5
  81. package/src/test/test-editor.ts +5 -24
  82. package/src/testing/index.ts +18 -14
  83. package/src/types/extension.spec.ts +1 -4
  84. package/src/types/extension.ts +3 -13
  85. package/src/types/simplify-union.ts +1 -4
  86. package/src/utils/array-grouping.spec.ts +1 -4
  87. package/src/utils/attrs-match.ts +1 -5
  88. package/src/utils/clsx.spec.ts +1 -4
  89. package/src/utils/combine-event-handlers.spec.ts +1 -5
  90. package/src/utils/default-block-at.ts +1 -4
  91. package/src/utils/editor-content.spec.ts +1 -4
  92. package/src/utils/editor-content.ts +4 -16
  93. package/src/utils/find-node.ts +65 -0
  94. package/src/utils/find-parent-node-of-type.ts +6 -12
  95. package/src/utils/find-parent-node.spec.ts +1 -5
  96. package/src/utils/find-parent-node.ts +1 -4
  97. package/src/utils/get-custom-selection.ts +1 -5
  98. package/src/utils/get-mark-type.ts +1 -4
  99. package/src/utils/get-node-type.ts +1 -4
  100. package/src/utils/get-node-types.ts +1 -4
  101. package/src/utils/includes-mark.ts +1 -5
  102. package/src/utils/is-at-block-start.ts +1 -4
  103. package/src/utils/is-mark-absent.spec.ts +1 -4
  104. package/src/utils/is-mark-absent.ts +1 -5
  105. package/src/utils/is-mark-active.ts +1 -4
  106. package/src/utils/is-node-active.spec.ts +109 -0
  107. package/src/utils/is-node-active.ts +17 -8
  108. package/src/utils/is-subset.spec.ts +1 -4
  109. package/src/utils/maybe-run.spec.ts +1 -5
  110. package/src/utils/merge-objects.spec.ts +1 -4
  111. package/src/utils/merge-objects.ts +2 -1
  112. package/src/utils/merge-specs.ts +1 -4
  113. package/src/utils/object-equal.spec.ts +1 -4
  114. package/src/utils/output-spec.test.ts +1 -5
  115. package/src/utils/output-spec.ts +2 -10
  116. package/src/utils/parse.spec.ts +2 -11
  117. package/src/utils/parse.ts +3 -15
  118. package/src/utils/remove-undefined-values.spec.ts +1 -4
  119. package/src/utils/set-selection-around.ts +1 -4
  120. package/src/utils/type-assertion.ts +2 -21
  121. package/src/utils/unicode.spec.ts +1 -4
  122. package/dist/editor-4lgGc3CY.d.ts.map +0 -1
  123. 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.10.0",
5
5
  "private": false,
6
6
  "description": "Core features for ProseKit",
7
7
  "author": {
@@ -40,19 +40,18 @@
40
40
  "src"
41
41
  ],
42
42
  "dependencies": {
43
- "@ocavue/utils": "^1.2.0",
43
+ "@ocavue/utils": "^1.4.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.4.3",
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.20.1",
54
53
  "typescript": "~5.9.3",
55
- "vitest": "^4.0.15",
54
+ "vitest": "^4.0.18",
56
55
  "vitest-browser-commands": "^0.2.0",
57
56
  "@prosekit/config-vitest": "0.0.0",
58
57
  "@prosekit/config-tsdown": "0.0.0"
@@ -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,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
 
@@ -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,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 { isTextSelection } from '../utils/type-assertion'
7
4
 
@@ -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
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { setupTest } from '../testing'
4
+
5
+ import { setNodeAttrs } from './set-node-attrs'
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
+ })
@@ -1,9 +1,7 @@
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
 
4
+ import { findParentNodeOfType } from '../utils/find-parent-node-of-type'
7
5
  import { getNodeTypes } from '../utils/get-node-types'
8
6
 
9
7
  /**
@@ -12,8 +10,6 @@ import { getNodeTypes } from '../utils/get-node-types'
12
10
  export interface SetNodeAttrsOptions {
13
11
  /**
14
12
  * The type of node to set the attributes of.
15
- *
16
- * If current node is not of this type, the command will do nothing.
17
13
  */
18
14
  type: string | NodeType | string[] | NodeType[]
19
15
 
@@ -23,43 +19,46 @@ export interface SetNodeAttrsOptions {
23
19
  attrs: Attrs
24
20
 
25
21
  /**
26
- * The position of the node. Defaults to the position of the wrapping node
27
- * containing the current selection.
22
+ * The document position of the node to update. If not provided, the command
23
+ * will find the closest ancestor node that matches the type based on the
24
+ * anchor position of the selection.
28
25
  */
29
26
  pos?: number
30
27
  }
31
28
 
32
29
  /**
33
- * Returns a command that set the attributes of the current node.
30
+ * Returns a command that sets the attributes of the current node.
31
+ *
32
+ * @param options
34
33
  *
35
34
  * @public
36
35
  */
37
- export function setNodeAttrs(options: SetNodeAttrsOptions): Command {
36
+ export function setNodeAttrs({ type, attrs, pos }: SetNodeAttrsOptions): Command {
38
37
  return (state, dispatch) => {
39
- const nodeTypes = getNodeTypes(state.schema, options.type)
40
- const from = options.pos ?? state.selection.from
41
- const to = options.pos ?? state.selection.to
42
- const positions: number[] = []
38
+ let updatePos: number
43
39
 
44
- state.doc.nodesBetween(from, to, (node, pos) => {
45
- if (nodeTypes.includes(node.type)) {
46
- positions.push(pos)
40
+ if (pos == null) {
41
+ const found = findParentNodeOfType(type, state.selection.$anchor)
42
+ if (!found) {
43
+ return false
47
44
  }
48
- if (!dispatch && positions.length > 0) {
45
+ updatePos = found.pos
46
+ } else {
47
+ const found = state.doc.nodeAt(pos)
48
+ if (!found) {
49
49
  return false
50
50
  }
51
- })
52
-
53
- if (positions.length === 0) {
54
- return false
51
+ const nodeTypes = getNodeTypes(state.schema, type)
52
+ if (!nodeTypes.includes(found.type)) {
53
+ return false
54
+ }
55
+ updatePos = pos
55
56
  }
56
57
 
57
58
  if (dispatch) {
58
59
  const { tr } = state
59
- for (const pos of positions) {
60
- for (const [key, value] of Object.entries(options.attrs)) {
61
- tr.setNodeAttribute(pos, key, value)
62
- }
60
+ for (const [key, value] of Object.entries(attrs)) {
61
+ tr.setNodeAttribute(updatePos, key, value)
63
62
  }
64
63
  dispatch(tr)
65
64
  }