@portabletext/editor 3.2.1 → 3.2.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portabletext/editor",
3
- "version": "3.2.1",
3
+ "version": "3.2.3",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -73,7 +73,7 @@
73
73
  "slate-dom": "^0.119.0",
74
74
  "slate-react": "0.120.0",
75
75
  "xstate": "^5.24.0",
76
- "@portabletext/block-tools": "^4.1.4",
76
+ "@portabletext/block-tools": "^4.1.5",
77
77
  "@portabletext/keyboard-shortcuts": "^2.1.0",
78
78
  "@portabletext/patches": "^2.0.0",
79
79
  "@portabletext/schema": "^2.0.0"
@@ -81,39 +81,39 @@
81
81
  "devDependencies": {
82
82
  "@sanity/diff-match-patch": "^3.2.0",
83
83
  "@sanity/pkg-utils": "^10.0.0",
84
- "@sanity/schema": "^4.20.0",
85
- "@sanity/types": "^4.20.0",
84
+ "@sanity/schema": "^4.20.1",
85
+ "@sanity/types": "^4.20.1",
86
86
  "@types/debug": "^4.1.12",
87
87
  "@types/lodash": "^4.17.20",
88
88
  "@types/lodash.startcase": "^4.4.9",
89
89
  "@types/node": "^20",
90
- "@types/react": "^19.2.2",
91
- "@types/react-dom": "^19.2.2",
90
+ "@types/react": "^19.2.7",
91
+ "@types/react-dom": "^19.2.3",
92
92
  "@vitejs/plugin-react": "^5.0.4",
93
93
  "@vitest/browser": "^4.0.14",
94
94
  "@vitest/browser-playwright": "^4.0.14",
95
95
  "@vitest/coverage-istanbul": "^4.0.14",
96
96
  "babel-plugin-react-compiler": "1.0.0",
97
- "eslint": "^9.38.0",
97
+ "eslint": "^9.39.1",
98
98
  "eslint-formatter-gha": "^1.6.0",
99
99
  "eslint-plugin-react-hooks": "7.0.1",
100
100
  "jsdom": "^27.0.0",
101
- "react": "^19.2.0",
102
- "react-dom": "^19.2.0",
101
+ "react": "^19.2.1",
102
+ "react-dom": "^19.2.1",
103
103
  "rxjs": "^7.8.2",
104
104
  "typescript": "5.9.3",
105
- "typescript-eslint": "^8.46.1",
105
+ "typescript-eslint": "^8.48.0",
106
106
  "vite": "^7.1.12",
107
107
  "vitest": "^4.0.14",
108
108
  "vitest-browser-react": "^2.0.2",
109
- "@portabletext/sanity-bridge": "1.2.7",
109
+ "@portabletext/sanity-bridge": "1.2.8",
110
110
  "@portabletext/test": "^1.0.0",
111
111
  "racejar": "2.0.0"
112
112
  },
113
113
  "peerDependencies": {
114
- "@portabletext/sanity-bridge": "^1.2.7",
115
- "@sanity/schema": "^4.20.0",
116
- "@sanity/types": "^4.20.0",
114
+ "@portabletext/sanity-bridge": "^1.2.8",
115
+ "@sanity/schema": "^4.20.1",
116
+ "@sanity/types": "^4.20.1",
117
117
  "react": "^18.3 || ^19",
118
118
  "rxjs": "^7.8.2"
119
119
  },
@@ -356,12 +356,16 @@ export function createWithPortableTextMarkModel(
356
356
  op.properties.focus.offset === 0 &&
357
357
  newFocusSpan.text.length === op.newProperties.focus.offset
358
358
 
359
- // We only want to clear the decorator state if the caret is visually
360
- // moving
359
+ // In the case of a collapsed selection moving to another collapsed
360
+ // selection, we only want to clear the decorator state if the
361
+ // caret is visually moving to a different span.
361
362
  if (!movedToNextSpan && !movedToPreviousSpan) {
362
363
  editor.decoratorState = {}
363
364
  }
364
365
  }
366
+ } else {
367
+ // In any other case, we want to clear the decorator state.
368
+ editor.decoratorState = {}
365
369
  }
366
370
  }
367
371
 
@@ -1,11 +1,18 @@
1
1
  import {isSpan, isTextBlock} from '@portabletext/schema'
2
- import {deleteText, Editor, Element, Range, Transforms} from 'slate'
2
+ import {
3
+ deleteText,
4
+ Editor,
5
+ Element,
6
+ Path,
7
+ Point,
8
+ Range,
9
+ Transforms,
10
+ type NodeEntry,
11
+ } from 'slate'
3
12
  import {DOMEditor} from 'slate-dom'
4
- import {slateRangeToSelection} from '../internal-utils/slate-utils'
5
13
  import {toSlateRange} from '../internal-utils/to-slate-range'
6
14
  import {VOID_CHILD_KEY} from '../internal-utils/values'
7
15
  import type {PortableTextSlateEditor} from '../types/editor'
8
- import {getBlockKeyFromSelectionPoint} from '../utils/util.selection-point'
9
16
  import type {BehaviorOperationImplementation} from './behavior.operations'
10
17
 
11
18
  export const deleteOperationImplementation: BehaviorOperationImplementation<
@@ -20,41 +27,18 @@ export const deleteOperationImplementation: BehaviorOperationImplementation<
20
27
  },
21
28
  blockIndexMap: operation.editor.blockIndexMap,
22
29
  })
23
- : undefined
30
+ : operation.editor.selection
24
31
 
25
- const selection = operation.editor.selection
26
- ? slateRangeToSelection({
27
- schema: context.schema,
28
- editor: operation.editor,
29
- range: operation.editor.selection,
30
- })
31
- : undefined
32
+ if (!at) {
33
+ throw new Error('Unable to delete without a selection')
34
+ }
32
35
 
33
- const reverse = operation.direction === 'backward'
34
- const anchorPoint = operation.at?.anchor ?? selection?.anchor
35
- const focusPoint = operation.at?.focus ?? selection?.focus
36
- const startPoint = reverse ? focusPoint : anchorPoint
37
- const endPoint = reverse ? anchorPoint : focusPoint
38
- const startBlockKey = startPoint
39
- ? getBlockKeyFromSelectionPoint(startPoint)
40
- : undefined
41
- const endBlockKey = endPoint
42
- ? getBlockKeyFromSelectionPoint(endPoint)
43
- : undefined
44
- const startBlockIndex = startBlockKey
45
- ? operation.editor.blockIndexMap.get(startBlockKey)
46
- : undefined
47
- const endBlockIndex = endBlockKey
48
- ? operation.editor.blockIndexMap.get(endBlockKey)
49
- : undefined
50
- const startBlock = startBlockIndex
51
- ? operation.editor.value.at(startBlockIndex)
52
- : undefined
53
- const endBlock = endBlockIndex
54
- ? operation.editor.value.at(endBlockIndex)
55
- : undefined
36
+ const [start, end] = Range.edges(at)
56
37
 
57
38
  if (operation.unit === 'block') {
39
+ const startBlockIndex = start.path.at(0)
40
+ const endBlockIndex = end.path.at(0)
41
+
58
42
  if (startBlockIndex === undefined || endBlockIndex === undefined) {
59
43
  throw new Error('Failed to get start or end block index')
60
44
  }
@@ -71,14 +55,8 @@ export const deleteOperationImplementation: BehaviorOperationImplementation<
71
55
  }
72
56
 
73
57
  if (operation.unit === 'child') {
74
- const range = at ?? operation.editor.selection ?? undefined
75
-
76
- if (!range) {
77
- throw new Error('Unable to delete children without a selection')
78
- }
79
-
80
58
  Transforms.removeNodes(operation.editor, {
81
- at: range,
59
+ at,
82
60
  match: (node) =>
83
61
  (isSpan(context, node) && node._key !== VOID_CHILD_KEY) ||
84
62
  ('__inline' in node && node.__inline === true),
@@ -88,15 +66,9 @@ export const deleteOperationImplementation: BehaviorOperationImplementation<
88
66
  }
89
67
 
90
68
  if (operation.direction === 'backward' && operation.unit === 'line') {
91
- const range = at ?? operation.editor.selection ?? undefined
92
-
93
- if (!range) {
94
- throw new Error('Unable to delete line without a selection')
95
- }
96
-
97
69
  const parentBlockEntry = Editor.above(operation.editor, {
98
70
  match: (n) => Element.isElement(n) && Editor.isBlock(operation.editor, n),
99
- at: range,
71
+ at,
100
72
  })
101
73
 
102
74
  if (parentBlockEntry) {
@@ -104,7 +76,7 @@ export const deleteOperationImplementation: BehaviorOperationImplementation<
104
76
  const parentElementRange = Editor.range(
105
77
  operation.editor,
106
78
  parentBlockPath,
107
- range.anchor,
79
+ at.anchor,
108
80
  )
109
81
 
110
82
  const currentLineRange = findCurrentLineRange(
@@ -120,15 +92,9 @@ export const deleteOperationImplementation: BehaviorOperationImplementation<
120
92
  }
121
93
 
122
94
  if (operation.unit === 'word') {
123
- const range = at ?? operation.editor.selection ?? undefined
124
-
125
- if (!range) {
126
- throw new Error('Unable to delete word without a selection')
127
- }
128
-
129
- if (Range.isCollapsed(range)) {
95
+ if (Range.isCollapsed(at)) {
130
96
  deleteText(operation.editor, {
131
- at: range,
97
+ at,
132
98
  unit: 'word',
133
99
  reverse: operation.direction === 'backward',
134
100
  })
@@ -137,19 +103,122 @@ export const deleteOperationImplementation: BehaviorOperationImplementation<
137
103
  }
138
104
  }
139
105
 
106
+ const startBlock = Editor.above(operation.editor, {
107
+ match: (n) => Element.isElement(n) && Editor.isBlock(operation.editor, n),
108
+ at: start,
109
+ voids: false,
110
+ })
111
+ const endBlock = Editor.above(operation.editor, {
112
+ match: (n) => Element.isElement(n) && Editor.isBlock(operation.editor, n),
113
+ at: end,
114
+ voids: false,
115
+ })
116
+ const isAcrossBlocks =
117
+ startBlock && endBlock && !Path.equals(startBlock[1], endBlock[1])
118
+ const startNonEditable =
119
+ Editor.void(operation.editor, {at: start, mode: 'highest'}) ??
120
+ Editor.elementReadOnly(operation.editor, {at: start, mode: 'highest'})
121
+ const endNonEditable =
122
+ Editor.void(operation.editor, {at: end, mode: 'highest'}) ??
123
+ Editor.elementReadOnly(operation.editor, {at: end, mode: 'highest'})
124
+
125
+ const matches: NodeEntry[] = []
126
+ let lastPath: Path | undefined
127
+
128
+ for (const entry of Editor.nodes(operation.editor, {at, voids: false})) {
129
+ const [node, path] = entry
130
+
131
+ if (lastPath && Path.compare(path, lastPath) === 0) {
132
+ continue
133
+ }
134
+
135
+ if (
136
+ (Element.isElement(node) &&
137
+ (Editor.isVoid(operation.editor, node) ||
138
+ Editor.isElementReadOnly(operation.editor, node))) ||
139
+ (!Path.isCommon(path, start.path) && !Path.isCommon(path, end.path))
140
+ ) {
141
+ matches.push(entry)
142
+ lastPath = path
143
+ }
144
+ }
145
+
146
+ const pathRefs = Array.from(matches, ([, path]) =>
147
+ Editor.pathRef(operation.editor, path),
148
+ )
149
+ const startRef = Editor.pointRef(operation.editor, start)
150
+ const endRef = Editor.pointRef(operation.editor, end)
151
+
152
+ const endToEndSelection =
153
+ startBlock &&
154
+ endBlock &&
155
+ Point.equals(start, Editor.start(operation.editor, startBlock[1])) &&
156
+ Point.equals(end, Editor.end(operation.editor, endBlock[1]))
157
+
158
+ if (
159
+ endToEndSelection &&
160
+ isAcrossBlocks &&
161
+ !startNonEditable &&
162
+ !endNonEditable
163
+ ) {
164
+ if (!startNonEditable) {
165
+ const point = startRef.current!
166
+ const [node] = Editor.leaf(operation.editor, point)
167
+
168
+ if (node.text.length > 0) {
169
+ operation.editor.apply({
170
+ type: 'remove_text',
171
+ path: point.path,
172
+ offset: 0,
173
+ text: node.text,
174
+ })
175
+ }
176
+ }
177
+
178
+ for (const pathRef of pathRefs.reverse()) {
179
+ const path = pathRef.unref()
180
+
181
+ if (path) {
182
+ Transforms.removeNodes(operation.editor, {at: path, voids: false})
183
+ }
184
+ }
185
+
186
+ if (!endNonEditable) {
187
+ const point = endRef.current!
188
+ const [node] = Editor.leaf(operation.editor, point)
189
+ const {path} = point
190
+ const offset = 0
191
+ const text = node.text.slice(offset, end.offset)
192
+
193
+ if (text.length > 0) {
194
+ operation.editor.apply({type: 'remove_text', path, offset, text})
195
+ }
196
+ }
197
+
198
+ if (endRef.current && startRef.current) {
199
+ Transforms.removeNodes(operation.editor, {
200
+ at: endRef.current,
201
+ voids: false,
202
+ })
203
+ }
204
+
205
+ return
206
+ }
207
+
208
+ const reverse = operation.direction === 'backward'
140
209
  const hanging = reverse
141
- ? endPoint
210
+ ? end
142
211
  ? isTextBlock(context, endBlock)
143
- ? endPoint.offset === 0
212
+ ? end.offset === 0
144
213
  : true
145
214
  : false
146
- : startPoint
215
+ : start
147
216
  ? isTextBlock(context, startBlock)
148
- ? startPoint.offset === 0
217
+ ? start.offset === 0
149
218
  : true
150
219
  : false
151
220
 
152
- if (at) {
221
+ if (operation.at) {
153
222
  deleteText(operation.editor, {
154
223
  at,
155
224
  hanging,
@@ -30,6 +30,10 @@ const parameterType = {
30
30
  name: 'decorator',
31
31
  matcher: /"(code|em|strong)"/,
32
32
  }),
33
+ direction: createParameterType<'forwards' | 'backwards'>({
34
+ name: 'direction',
35
+ matcher: /"(forwards|backwards)"/,
36
+ }),
33
37
  index: createParameterType({
34
38
  name: 'index',
35
39
  matcher: /"(\d)"/,
@@ -93,6 +97,7 @@ export const parameterTypes = [
93
97
  parameterType.blockObject,
94
98
  parameterType.button,
95
99
  parameterType.decorator,
100
+ parameterType.direction,
96
101
  parameterType.index,
97
102
  parameterType.inlineObject,
98
103
  parameterType.key,
@@ -227,6 +227,9 @@ export const stepDefinitions = [
227
227
  const previousSelection = context.editor.getSnapshot().context.selection
228
228
  await userEvent.keyboard(button)
229
229
 
230
+ // Small delay to allow the browser to process the event
231
+ await new Promise((resolve) => setTimeout(resolve, 50))
232
+
230
233
  await vi.waitFor(() => {
231
234
  const currentSelection = context.editor.getSnapshot().context.selection
232
235
 
@@ -242,6 +245,9 @@ export const stepDefinitions = [
242
245
  const previousSelection = context.editorB.getSnapshot().context.selection
243
246
  await userEvent.keyboard(button)
244
247
 
248
+ // Small delay to allow the browser to process the event
249
+ await new Promise((resolve) => setTimeout(resolve, 50))
250
+
245
251
  await vi.waitFor(() => {
246
252
  const currentSelection = context.editorB.getSnapshot().context.selection
247
253
 
@@ -258,6 +264,8 @@ export const stepDefinitions = [
258
264
  const previousSelection = context.editor.getSnapshot().context.selection
259
265
  await userEvent.keyboard(button)
260
266
 
267
+ await new Promise((resolve) => setTimeout(resolve, 50))
268
+
261
269
  await vi.waitFor(() => {
262
270
  const currentSelection =
263
271
  context.editor.getSnapshot().context.selection
@@ -277,6 +285,9 @@ export const stepDefinitions = [
277
285
  context.editorB.getSnapshot().context.selection
278
286
  await userEvent.keyboard(button)
279
287
 
288
+ // Small delay to allow the browser to process the event
289
+ await new Promise((resolve) => setTimeout(resolve, 50))
290
+
280
291
  await vi.waitFor(() => {
281
292
  const currentSelection =
282
293
  context.editorB.getSnapshot().context.selection
@@ -303,6 +314,9 @@ export const stepDefinitions = [
303
314
  const previousSelection = context.editor.getSnapshot().context.selection
304
315
  await userEvent.keyboard(shortcuts[shortcut])
305
316
 
317
+ // Small delay to allow the browser to process the event
318
+ await new Promise((resolve) => setTimeout(resolve, 50))
319
+
306
320
  await vi.waitFor(() => {
307
321
  const currentSelection = context.editor.getSnapshot().context.selection
308
322
 
@@ -471,6 +485,28 @@ export const stepDefinitions = [
471
485
  })
472
486
  })
473
487
  }),
488
+ When(
489
+ '{string} is selected {direction}',
490
+ async (
491
+ context: Context,
492
+ text: string,
493
+ direction: Parameter['direction'],
494
+ ) => {
495
+ await vi.waitFor(() => {
496
+ const selection = getTextSelection(
497
+ context.editor.getSnapshot().context,
498
+ text,
499
+ )
500
+ expect(selection).not.toBeNull()
501
+
502
+ context.editor.send({
503
+ type: 'select',
504
+ at:
505
+ direction === 'forwards' ? selection : reverseSelection(selection),
506
+ })
507
+ })
508
+ },
509
+ ),
474
510
  When(
475
511
  '{string} is selected backwards',
476
512
  async (context: Context, text: string) => {