@portabletext/editor 3.0.8 → 3.0.9

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.
@@ -115,7 +115,7 @@ declare function getTextBlockText(block: PortableTextTextBlock): string;
115
115
  /**
116
116
  * @public
117
117
  */
118
- declare function isEmptyTextBlock(context: Pick<EditorContext, 'schema'>, block: PortableTextBlock): boolean;
118
+ declare function isEmptyTextBlock(context: Pick<EditorContext, 'schema'>, block: PortableTextBlock | unknown): boolean;
119
119
  /**
120
120
  * @public
121
121
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portabletext/editor",
3
- "version": "3.0.8",
3
+ "version": "3.0.9",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -25,7 +25,6 @@ import {getEventPosition} from '../internal-utils/event-position'
25
25
  import {normalizeSelection} from '../internal-utils/selection'
26
26
  import {slateRangeToSelection} from '../internal-utils/slate-utils'
27
27
  import {toSlateRange} from '../internal-utils/to-slate-range'
28
- import {isEqualToEmptyEditor} from '../internal-utils/values'
29
28
  import type {
30
29
  EditorSelection,
31
30
  OnCopyFn,
@@ -41,6 +40,7 @@ import type {
41
40
  ScrollSelectionIntoViewFunction,
42
41
  } from '../types/editor'
43
42
  import type {HotkeyOptions} from '../types/options'
43
+ import {isEmptyTextBlock} from '../utils'
44
44
  import {parseBlocks} from '../utils/parse-blocks'
45
45
  import {RenderElement} from './components/render-element'
46
46
  import {RenderLeaf} from './components/render-leaf'
@@ -526,9 +526,10 @@ export const PortableTextEditable = forwardRef<
526
526
 
527
527
  if (
528
528
  !slateEditor.selection &&
529
- isEqualToEmptyEditor(
530
- slateEditor.children,
531
- editorActor.getSnapshot().context.schema,
529
+ slateEditor.children.length === 1 &&
530
+ isEmptyTextBlock(
531
+ editorActor.getSnapshot().context,
532
+ slateEditor.value.at(0),
532
533
  )
533
534
  ) {
534
535
  Transforms.select(slateEditor, Editor.start(slateEditor, []))
@@ -1,6 +1,7 @@
1
- import {Transforms, type Element as SlateElement} from 'slate'
2
- import {toSlateBlock} from '../internal-utils/values'
3
- import {parseBlock} from '../utils/parse-blocks'
1
+ import {applyAll, set} from '@portabletext/patches'
2
+ import {isTextBlock} from '@portabletext/schema'
3
+ import {Transforms, type Node} from 'slate'
4
+ import {parseMarkDefs} from '../utils/parse-blocks'
4
5
  import type {BehaviorOperationImplementation} from './behavior.operations'
5
6
 
6
7
  export const blockSetOperationImplementation: BehaviorOperationImplementation<
@@ -14,36 +15,90 @@ export const blockSetOperationImplementation: BehaviorOperationImplementation<
14
15
  )
15
16
  }
16
17
 
17
- const block = operation.editor.value.at(blockIndex)
18
+ const slateBlock = operation.editor.children.at(blockIndex)
18
19
 
19
- if (!block) {
20
+ if (!slateBlock) {
20
21
  throw new Error(`Unable to find block at ${JSON.stringify(operation.at)}`)
21
22
  }
22
23
 
23
- const {_type, ...filteredProps} = operation.props
24
+ if (isTextBlock(context, slateBlock)) {
25
+ const filteredProps: Record<string, unknown> = {}
24
26
 
25
- const updatedBlock = {
26
- ...block,
27
- ...filteredProps,
28
- }
27
+ for (const key of Object.keys(operation.props)) {
28
+ if (key === '_type' || key === 'children') {
29
+ continue
30
+ }
29
31
 
30
- const parsedBlock = parseBlock({
31
- context,
32
- block: updatedBlock,
33
- options: {
34
- normalize: false,
35
- removeUnusedMarkDefs: false,
36
- validateFields: true,
37
- },
38
- })
39
-
40
- if (!parsedBlock) {
41
- throw new Error(`Unable to update block at ${JSON.stringify(operation.at)}`)
42
- }
32
+ if (key === 'style') {
33
+ if (
34
+ context.schema.styles.some(
35
+ (style) => style.name === operation.props[key],
36
+ )
37
+ ) {
38
+ filteredProps[key] = operation.props[key]
39
+ }
40
+ continue
41
+ }
42
+
43
+ if (key === 'listItem') {
44
+ if (
45
+ context.schema.lists.some(
46
+ (list) => list.name === operation.props[key],
47
+ )
48
+ ) {
49
+ filteredProps[key] = operation.props[key]
50
+ }
51
+ continue
52
+ }
53
+
54
+ if (key === 'level') {
55
+ filteredProps[key] = operation.props[key]
56
+ continue
57
+ }
58
+
59
+ if (key === 'markDefs') {
60
+ const {markDefs} = parseMarkDefs({
61
+ context,
62
+ markDefs: operation.props[key],
63
+ options: {validateFields: true},
64
+ })
65
+ filteredProps[key] = markDefs
66
+ continue
67
+ }
68
+
69
+ if (context.schema.block.fields?.some((field) => field.name === key)) {
70
+ filteredProps[key] = operation.props[key]
71
+ }
72
+ }
73
+
74
+ Transforms.setNodes(operation.editor, filteredProps, {at: [blockIndex]})
75
+ } else {
76
+ const schemaDefinition = context.schema.blockObjects.find(
77
+ (definition) => definition.name === slateBlock._type,
78
+ )
79
+ const filteredProps: Record<string, unknown> = {}
80
+
81
+ for (const key of Object.keys(operation.props)) {
82
+ if (key === '_type') {
83
+ continue
84
+ }
43
85
 
44
- const slateBlock = toSlateBlock(parsedBlock, {
45
- schemaTypes: context.schema,
46
- }) as SlateElement
86
+ if (key === '_key') {
87
+ filteredProps[key] = operation.props[key]
88
+ continue
89
+ }
47
90
 
48
- Transforms.setNodes(operation.editor, slateBlock, {at: [blockIndex]})
91
+ if (schemaDefinition?.fields.some((field) => field.name === key)) {
92
+ filteredProps[key] = operation.props[key]
93
+ }
94
+ }
95
+
96
+ const patches = Object.entries(filteredProps).map(([key, value]) =>
97
+ key === '_key' ? set(value, ['_key']) : set(value, ['value', key]),
98
+ )
99
+
100
+ const updatedSlateBlock = applyAll(slateBlock, patches) as Partial<Node>
101
+
102
+ Transforms.setNodes(operation.editor, updatedSlateBlock, {at: [blockIndex]})
103
+ }
49
104
  }
@@ -1,7 +1,6 @@
1
+ import {applyAll, set, unset} from '@portabletext/patches'
1
2
  import {isTextBlock} from '@portabletext/schema'
2
- import {omit} from 'lodash'
3
- import {Transforms} from 'slate'
4
- import {parseBlock} from '../utils/parse-blocks'
3
+ import {Transforms, type Node} from 'slate'
5
4
  import type {BehaviorOperationImplementation} from './behavior.operations'
6
5
 
7
6
  export const blockUnsetOperationImplementation: BehaviorOperationImplementation<
@@ -14,73 +13,42 @@ export const blockUnsetOperationImplementation: BehaviorOperationImplementation<
14
13
  throw new Error(`Unable to find block index for block key ${blockKey}`)
15
14
  }
16
15
 
17
- const block =
18
- blockIndex !== undefined ? operation.editor.value.at(blockIndex) : undefined
16
+ const slateBlock =
17
+ blockIndex !== undefined
18
+ ? operation.editor.children.at(blockIndex)
19
+ : undefined
19
20
 
20
- if (!block) {
21
+ if (!slateBlock) {
21
22
  throw new Error(`Unable to find block at ${JSON.stringify(operation.at)}`)
22
23
  }
23
24
 
24
- if (isTextBlock(context, block)) {
25
- const propsToRemove = operation.props.filter((prop) => prop !== '_type')
25
+ if (isTextBlock(context, slateBlock)) {
26
+ const propsToRemove = operation.props.filter(
27
+ (prop) => prop !== '_type' && prop !== '_key' && prop !== 'children',
28
+ )
26
29
 
27
- const updatedTextBlock = parseBlock({
28
- context,
29
- block: omit(block, propsToRemove),
30
- options: {
31
- normalize: false,
32
- removeUnusedMarkDefs: true,
33
- validateFields: true,
34
- },
35
- })
30
+ Transforms.unsetNodes(operation.editor, propsToRemove, {at: [blockIndex]})
36
31
 
37
- if (!updatedTextBlock) {
38
- throw new Error(
39
- `Unable to update block at ${JSON.stringify(operation.at)}`,
32
+ if (operation.props.includes('_key')) {
33
+ Transforms.setNodes(
34
+ operation.editor,
35
+ {_key: context.keyGenerator()},
36
+ {at: [blockIndex]},
40
37
  )
41
38
  }
42
39
 
43
- const propsToSet: Record<string, unknown> = {}
44
-
45
- for (const prop of propsToRemove) {
46
- if (!(prop in updatedTextBlock)) {
47
- propsToSet[prop] = undefined
48
- } else {
49
- propsToSet[prop] = (updatedTextBlock as Record<string, unknown>)[prop]
50
- }
51
- }
52
-
53
- Transforms.setNodes(operation.editor, propsToSet, {at: [blockIndex]})
54
-
55
40
  return
56
41
  }
57
42
 
58
- const updatedBlockObject = parseBlock({
59
- context,
60
- block: omit(
61
- block,
62
- operation.props.filter((prop) => prop !== '_type'),
63
- ),
64
- options: {
65
- normalize: false,
66
- removeUnusedMarkDefs: true,
67
- validateFields: true,
68
- },
69
- })
70
-
71
- if (!updatedBlockObject) {
72
- throw new Error(`Unable to update block at ${JSON.stringify(operation.at)}`)
73
- }
43
+ const patches = operation.props.flatMap((key) =>
44
+ key === '_type'
45
+ ? []
46
+ : key === '_key'
47
+ ? set(context.keyGenerator(), ['_key'])
48
+ : unset(['value', key]),
49
+ )
74
50
 
75
- const {_type, _key, ...props} = updatedBlockObject
51
+ const updatedSlateBlock = applyAll(slateBlock, patches) as Partial<Node>
76
52
 
77
- Transforms.setNodes(
78
- operation.editor,
79
- {
80
- _type,
81
- _key,
82
- value: props,
83
- },
84
- {at: [blockIndex]},
85
- )
53
+ Transforms.setNodes(operation.editor, updatedSlateBlock, {at: [blockIndex]})
86
54
  }
@@ -12,7 +12,7 @@ import {
12
12
  import {DOMEditor} from 'slate-dom'
13
13
  import {getFocusBlock, getFocusChild} from '../internal-utils/slate-utils'
14
14
  import {toSlateRange} from '../internal-utils/to-slate-range'
15
- import {isEqualToEmptyEditor, toSlateBlock} from '../internal-utils/values'
15
+ import {toSlateBlock} from '../internal-utils/values'
16
16
  import type {EditorSelection, PortableTextSlateEditor} from '../types/editor'
17
17
  import {parseBlock} from '../utils/parse-blocks'
18
18
  import {isEmptyTextBlock} from '../utils/util.is-empty-text-block'
@@ -121,7 +121,7 @@ export function insertBlock(options: {
121
121
  } else {
122
122
  // placement === 'auto'
123
123
 
124
- if (endBlock && isEqualToEmptyEditor([endBlock], context.schema)) {
124
+ if (isEmptyTextBlock(context, endBlock)) {
125
125
  Transforms.insertNodes(editor, [block], {
126
126
  at: endBlockPath,
127
127
  select: false,
@@ -234,7 +234,7 @@ export function insertBlock(options: {
234
234
  Transforms.select(editor, atAfterInsert)
235
235
  }
236
236
 
237
- if (focusBlock && isEqualToEmptyEditor([focusBlock], context.schema)) {
237
+ if (isEmptyTextBlock(context, focusBlock)) {
238
238
  Transforms.removeNodes(editor, {at: focusBlockPath})
239
239
  }
240
240
 
@@ -244,7 +244,7 @@ export function insertBlock(options: {
244
244
  if (editor.isTextBlock(endBlock) && editor.isTextBlock(block)) {
245
245
  const selectionStartPoint = Range.start(at)
246
246
 
247
- if (isEqualToEmptyEditor([endBlock], context.schema)) {
247
+ if (isEmptyTextBlock(context, endBlock)) {
248
248
  Transforms.insertNodes(editor, [block], {
249
249
  at: endBlockPath,
250
250
  select: false,
@@ -144,45 +144,10 @@ export function parseTextBlock({
144
144
  const _key =
145
145
  typeof block._key === 'string' ? block._key : context.keyGenerator()
146
146
 
147
- const unparsedMarkDefs: Array<unknown> = Array.isArray(block.markDefs)
148
- ? block.markDefs
149
- : []
150
- const markDefKeyMap = new Map<string, string>()
151
- const markDefs = unparsedMarkDefs.flatMap((markDef) => {
152
- if (!isTypedObject(markDef)) {
153
- return []
154
- }
155
-
156
- const schemaType = context.schema.annotations.find(
157
- ({name}) => name === markDef._type,
158
- )
159
-
160
- if (!schemaType) {
161
- return []
162
- }
163
-
164
- if (typeof markDef._key !== 'string') {
165
- // If the `markDef` doesn't have a `_key` then we don't know what spans
166
- // it belongs to and therefore we have to discard it.
167
- return []
168
- }
169
-
170
- const parsedAnnotation = parseObject({
171
- object: markDef,
172
- context: {
173
- schemaType,
174
- keyGenerator: context.keyGenerator,
175
- },
176
- options,
177
- })
178
-
179
- if (!parsedAnnotation) {
180
- return []
181
- }
182
-
183
- markDefKeyMap.set(markDef._key, parsedAnnotation._key)
184
-
185
- return [parsedAnnotation]
147
+ const {markDefs, markDefKeyMap} = parseMarkDefs({
148
+ context,
149
+ markDefs: block.markDefs,
150
+ options,
186
151
  })
187
152
 
188
153
  const unparsedChildren: Array<unknown> = Array.isArray(block.children)
@@ -279,6 +244,66 @@ export function parseTextBlock({
279
244
  return parsedBlock
280
245
  }
281
246
 
247
+ export function parseMarkDefs({
248
+ context,
249
+ markDefs,
250
+ options,
251
+ }: {
252
+ context: Pick<EditorContext, 'keyGenerator' | 'schema'>
253
+ markDefs: unknown
254
+ options: {validateFields: boolean}
255
+ }): {
256
+ markDefs: Array<PortableTextObject>
257
+ markDefKeyMap: Map<string, string>
258
+ } {
259
+ const unparsedMarkDefs: Array<unknown> = Array.isArray(markDefs)
260
+ ? markDefs
261
+ : []
262
+ const markDefKeyMap = new Map<string, string>()
263
+
264
+ const parsedMarkDefs = unparsedMarkDefs.flatMap((markDef) => {
265
+ if (!isTypedObject(markDef)) {
266
+ return []
267
+ }
268
+
269
+ const schemaType = context.schema.annotations.find(
270
+ ({name}) => name === markDef._type,
271
+ )
272
+
273
+ if (!schemaType) {
274
+ return []
275
+ }
276
+
277
+ if (typeof markDef._key !== 'string') {
278
+ // If the `markDef` doesn't have a `_key` then we don't know what spans
279
+ // it belongs to and therefore we have to discard it.
280
+ return []
281
+ }
282
+
283
+ const parsedAnnotation = parseObject({
284
+ object: markDef,
285
+ context: {
286
+ schemaType,
287
+ keyGenerator: context.keyGenerator,
288
+ },
289
+ options,
290
+ })
291
+
292
+ if (!parsedAnnotation) {
293
+ return []
294
+ }
295
+
296
+ markDefKeyMap.set(markDef._key, parsedAnnotation._key)
297
+
298
+ return [parsedAnnotation]
299
+ })
300
+
301
+ return {
302
+ markDefs: parsedMarkDefs,
303
+ markDefKeyMap,
304
+ }
305
+ }
306
+
282
307
  export function parseChild({
283
308
  child,
284
309
  context,
@@ -8,7 +8,7 @@ import {getTextBlockText} from './util.get-text-block-text'
8
8
  */
9
9
  export function isEmptyTextBlock(
10
10
  context: Pick<EditorContext, 'schema'>,
11
- block: PortableTextBlock,
11
+ block: PortableTextBlock | unknown,
12
12
  ) {
13
13
  if (!isTextBlock(context, block)) {
14
14
  return false
@@ -1,212 +0,0 @@
1
- import type {EditorSchema} from '../editor/editor-schema'
2
- import {createPairRegex} from '../internal-utils/get-text-to-emphasize'
3
- import {getFocusTextBlock} from '../selectors/selector.get-focus-text-block'
4
- import {getPreviousInlineObject} from '../selectors/selector.get-previous-inline-object'
5
- import {getSelectionStartPoint} from '../selectors/selector.get-selection-start-point'
6
- import {getBlockTextBefore} from '../selectors/selector.get-text-before'
7
- import type {BlockOffset} from '../types/block-offset'
8
- import {spanSelectionPointToBlockOffset} from '../utils/util.block-offset'
9
- import {blockOffsetsToSelection} from '../utils/util.block-offsets-to-selection'
10
- import {childSelectionPointToBlockOffset} from '../utils/util.child-selection-point-to-block-offset'
11
- import {effect, execute} from './behavior.types.action'
12
- import {defineBehavior} from './behavior.types.behavior'
13
-
14
- export function createDecoratorPairBehavior(config: {
15
- decorator: ({schema}: {schema: EditorSchema}) => string | undefined
16
- pair: {char: string; amount: number}
17
- onDecorate: (offset: BlockOffset) => void
18
- }) {
19
- if (config.pair.amount < 1) {
20
- console.warn(
21
- `The amount of characters in the pair should be greater than 0`,
22
- )
23
- }
24
-
25
- const pairRegex = createPairRegex(config.pair.char, config.pair.amount)
26
- const regEx = new RegExp(`(${pairRegex})$`)
27
-
28
- return defineBehavior({
29
- on: 'insert.text',
30
- guard: ({snapshot, event}) => {
31
- if (config.pair.amount < 1) {
32
- return false
33
- }
34
-
35
- const decorator = config.decorator({schema: snapshot.context.schema})
36
-
37
- if (decorator === undefined) {
38
- return false
39
- }
40
-
41
- const focusTextBlock = getFocusTextBlock(snapshot)
42
- const selectionStartPoint = getSelectionStartPoint(snapshot)
43
- const selectionStartOffset = selectionStartPoint
44
- ? spanSelectionPointToBlockOffset({
45
- context: {
46
- schema: snapshot.context.schema,
47
- value: snapshot.context.value,
48
- },
49
- selectionPoint: selectionStartPoint,
50
- })
51
- : undefined
52
-
53
- if (!focusTextBlock || !selectionStartOffset) {
54
- return false
55
- }
56
-
57
- const textBefore = getBlockTextBefore(snapshot)
58
- const newText = `${textBefore}${event.text}`
59
- const textToDecorate = newText.match(regEx)?.at(0)
60
-
61
- if (textToDecorate === undefined) {
62
- return false
63
- }
64
-
65
- const prefixOffsets = {
66
- anchor: {
67
- path: focusTextBlock.path,
68
- // Example: "foo **bar**".length - "**bar**".length = 4
69
- offset: newText.length - textToDecorate.length,
70
- },
71
- focus: {
72
- path: focusTextBlock.path,
73
- // Example: "foo **bar**".length - "**bar**".length + "*".length * 2 = 6
74
- offset:
75
- newText.length -
76
- textToDecorate.length +
77
- config.pair.char.length * config.pair.amount,
78
- },
79
- }
80
-
81
- const suffixOffsets = {
82
- anchor: {
83
- path: focusTextBlock.path,
84
- // Example: "foo **bar*|" (10) + "*".length - 2 = 9
85
- offset:
86
- selectionStartOffset.offset +
87
- event.text.length -
88
- config.pair.char.length * config.pair.amount,
89
- },
90
- focus: {
91
- path: focusTextBlock.path,
92
- // Example: "foo **bar*|" (10) + "*".length = 11
93
- offset: selectionStartOffset.offset + event.text.length,
94
- },
95
- }
96
-
97
- // If the prefix is more than one character, then we need to check if
98
- // there is an inline object inside it
99
- if (prefixOffsets.focus.offset - prefixOffsets.anchor.offset > 1) {
100
- const prefixSelection = blockOffsetsToSelection({
101
- context: snapshot.context,
102
- offsets: prefixOffsets,
103
- })
104
- const inlineObjectBeforePrefixFocus = getPreviousInlineObject({
105
- ...snapshot,
106
- context: {
107
- ...snapshot.context,
108
- selection: prefixSelection
109
- ? {
110
- anchor: prefixSelection.focus,
111
- focus: prefixSelection.focus,
112
- }
113
- : null,
114
- },
115
- })
116
- const inlineObjectBeforePrefixFocusOffset =
117
- inlineObjectBeforePrefixFocus
118
- ? childSelectionPointToBlockOffset({
119
- context: {
120
- schema: snapshot.context.schema,
121
- value: snapshot.context.value,
122
- },
123
- selectionPoint: {
124
- path: inlineObjectBeforePrefixFocus.path,
125
- offset: 0,
126
- },
127
- })
128
- : undefined
129
-
130
- if (
131
- inlineObjectBeforePrefixFocusOffset &&
132
- inlineObjectBeforePrefixFocusOffset.offset >
133
- prefixOffsets.anchor.offset &&
134
- inlineObjectBeforePrefixFocusOffset.offset <
135
- prefixOffsets.focus.offset
136
- ) {
137
- return false
138
- }
139
- }
140
-
141
- // If the suffix is more than one character, then we need to check if
142
- // there is an inline object inside it
143
- if (suffixOffsets.focus.offset - suffixOffsets.anchor.offset > 1) {
144
- const previousInlineObject = getPreviousInlineObject(snapshot)
145
- const previousInlineObjectOffset = previousInlineObject
146
- ? childSelectionPointToBlockOffset({
147
- context: {
148
- schema: snapshot.context.schema,
149
- value: snapshot.context.value,
150
- },
151
- selectionPoint: {
152
- path: previousInlineObject.path,
153
- offset: 0,
154
- },
155
- })
156
- : undefined
157
-
158
- if (
159
- previousInlineObjectOffset &&
160
- previousInlineObjectOffset.offset > suffixOffsets.anchor.offset &&
161
- previousInlineObjectOffset.offset < suffixOffsets.focus.offset
162
- ) {
163
- return false
164
- }
165
- }
166
-
167
- return {
168
- prefixOffsets,
169
- suffixOffsets,
170
- decorator,
171
- }
172
- },
173
- actions: [
174
- // Insert the text as usual in its own undo step
175
- ({event}) => [execute(event)],
176
- (_, {prefixOffsets, suffixOffsets, decorator}) => [
177
- // Decorate the text between the prefix and suffix
178
- execute({
179
- type: 'decorator.add',
180
- decorator,
181
- at: {
182
- anchor: prefixOffsets.focus,
183
- focus: suffixOffsets.anchor,
184
- },
185
- }),
186
- // Delete the suffix
187
- execute({
188
- type: 'delete.text',
189
- at: suffixOffsets,
190
- }),
191
- // Delete the prefix
192
- execute({
193
- type: 'delete.text',
194
- at: prefixOffsets,
195
- }),
196
- // Toggle the decorator off so the next inserted text isn't emphasized
197
- execute({
198
- type: 'decorator.remove',
199
- decorator,
200
- }),
201
- effect(() => {
202
- config.onDecorate({
203
- ...suffixOffsets.anchor,
204
- offset:
205
- suffixOffsets.anchor.offset -
206
- (prefixOffsets.focus.offset - prefixOffsets.anchor.offset),
207
- })
208
- }),
209
- ],
210
- ],
211
- })
212
- }