@portabletext/editor 1.0.18 → 1.0.19

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.
@@ -18,7 +18,6 @@ import {
18
18
  } from '../../types/editor'
19
19
  import {debugWithName} from '../../utils/debug'
20
20
  import {toPortableTextRange} from '../../utils/ranges'
21
- import {EMPTY_MARKS} from '../../utils/values'
22
21
  import {isChangingRemotely} from '../../utils/withChanges'
23
22
  import {isRedoing, isUndoing} from '../../utils/withUndoRedo'
24
23
 
@@ -56,9 +55,6 @@ export function createWithPortableTextMarkModel(
56
55
  editor.normalizeNode = (nodeEntry) => {
57
56
  const [node, path] = nodeEntry
58
57
 
59
- const isSpan = Text.isText(node) && node._type === types.span.name
60
- const isTextBlock = editor.isTextBlock(node)
61
-
62
58
  if (editor.isTextBlock(node)) {
63
59
  const children = Node.children(editor, path)
64
60
 
@@ -81,131 +77,89 @@ export function createWithPortableTextMarkModel(
81
77
  }
82
78
  }
83
79
 
84
- if (isSpan || isTextBlock) {
85
- if (isSpan && !Array.isArray(node.marks)) {
86
- debug('Adding .marks to span node')
87
- Transforms.setNodes(editor, {marks: []}, {at: path})
88
- return
89
- }
90
- const hasSpanMarks = isSpan && (node.marks || []).length > 0
91
- if (hasSpanMarks) {
92
- const spanMarks = node.marks || EMPTY_MARKS
93
- // Test that every annotation mark used has a definition in markDefs
94
- const annotationMarks = spanMarks.filter(
95
- (mark) => !types.decorators.map((dec) => dec.value).includes(mark),
96
- )
97
- if (annotationMarks.length > 0) {
98
- const [block] = Editor.node(editor, Path.parent(path))
99
- const orphanedMarks =
100
- (editor.isTextBlock(block) &&
101
- annotationMarks.filter(
102
- (mark) => !block.markDefs?.find((def) => def._key === mark),
103
- )) ||
104
- []
105
- if (orphanedMarks.length > 0) {
106
- debug('Removing orphaned .marks from span node')
107
- Transforms.setNodes(
108
- editor,
109
- {marks: spanMarks.filter((mark) => !orphanedMarks.includes(mark))},
110
- {at: path},
111
- )
112
- return
113
- }
80
+ /**
81
+ * Add missing .marks to span nodes
82
+ */
83
+ if (editor.isTextSpan(node) && !Array.isArray(node.marks)) {
84
+ debug('Adding .marks to span node')
85
+ Transforms.setNodes(editor, {marks: []}, {at: path})
86
+ return
87
+ }
88
+
89
+ /**
90
+ * Remove annotations from empty spans
91
+ */
92
+ if (editor.isTextSpan(node)) {
93
+ const blockPath = Path.parent(path)
94
+ const [block] = Editor.node(editor, blockPath)
95
+ const decorators = types.decorators.map((decorator) => decorator.value)
96
+ const annotations = node.marks?.filter((mark) => !decorators.includes(mark))
97
+
98
+ if (editor.isTextBlock(block)) {
99
+ if (node.text === '' && annotations && annotations.length > 0) {
100
+ debug('Removing annotations from empty span node')
101
+ Transforms.setNodes(
102
+ editor,
103
+ {marks: node.marks?.filter((mark) => decorators.includes(mark))},
104
+ {at: path},
105
+ )
106
+ return
114
107
  }
115
108
  }
116
- for (const op of editor.operations) {
117
- // Make sure markDefs are copied over when merging two blocks.
118
- if (
119
- op.type === 'merge_node' &&
120
- op.path.length === 1 &&
121
- 'markDefs' in op.properties &&
122
- op.properties._type === types.block.name &&
123
- Array.isArray(op.properties.markDefs) &&
124
- op.properties.markDefs.length > 0 &&
125
- op.path[0] - 1 >= 0
126
- ) {
127
- const [targetBlock, targetPath] = Editor.node(editor, [op.path[0] - 1])
128
- debug(`Copying markDefs over to merged block`, op)
129
- if (editor.isTextBlock(targetBlock)) {
130
- const oldDefs = (Array.isArray(targetBlock.markDefs) && targetBlock.markDefs) || []
131
- const newMarkDefs = uniq([...oldDefs, ...op.properties.markDefs])
132
- const isNormalized = isEqual(newMarkDefs, targetBlock.markDefs)
133
- // eslint-disable-next-line max-depth
134
- if (!isNormalized) {
135
- Transforms.setNodes(editor, {markDefs: newMarkDefs}, {at: targetPath, voids: false})
136
- return
137
- }
138
- }
139
- }
140
- // Make sure markDefs are copied over to new block when splitting a block.
141
- if (
142
- op.type === 'split_node' &&
143
- op.path.length === 1 &&
144
- Element.isElementProps(op.properties) &&
145
- op.properties._type === types.block.name &&
146
- 'markDefs' in op.properties &&
147
- Array.isArray(op.properties.markDefs) &&
148
- op.properties.markDefs.length > 0 &&
149
- op.path[0] + 1 < editor.children.length
150
- ) {
151
- const [targetBlock, targetPath] = Editor.node(editor, [op.path[0] + 1])
152
- debug(`Copying markDefs over to split block`, op)
153
- if (editor.isTextBlock(targetBlock)) {
154
- const oldDefs = (Array.isArray(targetBlock.markDefs) && targetBlock.markDefs) || []
109
+ }
110
+
111
+ /**
112
+ * Remove orphaned annotations from child spans of block nodes
113
+ */
114
+ if (editor.isTextBlock(node)) {
115
+ const decorators = types.decorators.map((decorator) => decorator.value)
116
+
117
+ for (const [child, childPath] of Node.children(editor, path)) {
118
+ if (editor.isTextSpan(child)) {
119
+ const marks = child.marks ?? []
120
+ const orphanedAnnotations = marks.filter((mark) => {
121
+ return !decorators.includes(mark) && !node.markDefs?.find((def) => def._key === mark)
122
+ })
123
+
124
+ if (orphanedAnnotations.length > 0) {
125
+ debug('Removing orphaned annotations from span node')
155
126
  Transforms.setNodes(
156
127
  editor,
157
- {markDefs: uniq([...oldDefs, ...op.properties.markDefs])},
158
- {at: targetPath, voids: false},
128
+ {marks: marks.filter((mark) => !orphanedAnnotations.includes(mark))},
129
+ {at: childPath},
159
130
  )
160
131
  return
161
132
  }
162
133
  }
163
- // Make sure marks are reset, if a block is split at the end.
164
- if (
165
- op.type === 'split_node' &&
166
- op.path.length === 2 &&
167
- (op.properties as unknown as Descendant)._type === types.span.name &&
168
- 'marks' in op.properties &&
169
- Array.isArray(op.properties.marks) &&
170
- op.properties.marks.length > 0 &&
171
- op.path[0] + 1 < editor.children.length
172
- ) {
173
- const [child, childPath] = Editor.node(editor, [op.path[0] + 1, 0])
174
- if (
175
- Text.isText(child) &&
176
- child.text === '' &&
177
- Array.isArray(child.marks) &&
178
- child.marks.length > 0
179
- ) {
180
- Transforms.setNodes(editor, {marks: []}, {at: childPath, voids: false})
181
- return
182
- }
183
- }
184
- // Make sure markDefs are reset, if a block is split at start.
185
- if (
186
- op.type === 'split_node' &&
187
- op.path.length === 1 &&
188
- (op.properties as unknown as Descendant)._type === types.block.name &&
189
- 'markDefs' in op.properties &&
190
- Array.isArray(op.properties.markDefs) &&
191
- op.properties.markDefs.length > 0
192
- ) {
193
- const [block, blockPath] = Editor.node(editor, [op.path[0]])
194
- if (
195
- editor.isTextBlock(block) &&
196
- block.children.length === 1 &&
197
- block.markDefs &&
198
- block.markDefs.length > 0 &&
199
- Text.isText(block.children[0]) &&
200
- block.children[0].text === '' &&
201
- (!block.children[0].marks || block.children[0].marks.length === 0)
202
- ) {
203
- Transforms.setNodes(editor, {markDefs: []}, {at: blockPath})
204
- return
205
- }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Remove orphaned annotations from span nodes
139
+ */
140
+ if (editor.isTextSpan(node)) {
141
+ const blockPath = Path.parent(path)
142
+ const [block] = Editor.node(editor, blockPath)
143
+
144
+ if (editor.isTextBlock(block)) {
145
+ const decorators = types.decorators.map((decorator) => decorator.value)
146
+ const marks = node.marks ?? []
147
+ const orphanedAnnotations = marks.filter((mark) => {
148
+ return !decorators.includes(mark) && !block.markDefs?.find((def) => def._key === mark)
149
+ })
150
+
151
+ if (orphanedAnnotations.length > 0) {
152
+ debug('Removing orphaned annotations from span node')
153
+ Transforms.setNodes(
154
+ editor,
155
+ {marks: marks.filter((mark) => !orphanedAnnotations.includes(mark))},
156
+ {at: path},
157
+ )
158
+ return
206
159
  }
207
160
  }
208
161
  }
162
+
209
163
  // Check consistency of markDefs (unless we are merging two nodes)
210
164
  if (
211
165
  editor.isTextBlock(node) &&
@@ -360,6 +314,31 @@ export function createWithPortableTextMarkModel(
360
314
  }
361
315
  }
362
316
 
317
+ /**
318
+ * Copy over markDefs when merging blocks
319
+ */
320
+ if (
321
+ op.type === 'merge_node' &&
322
+ op.path.length === 1 &&
323
+ 'markDefs' in op.properties &&
324
+ op.properties._type === types.block.name &&
325
+ Array.isArray(op.properties.markDefs) &&
326
+ op.properties.markDefs.length > 0 &&
327
+ op.path[0] - 1 >= 0
328
+ ) {
329
+ const [targetBlock, targetPath] = Editor.node(editor, [op.path[0] - 1])
330
+
331
+ if (editor.isTextBlock(targetBlock)) {
332
+ const oldDefs = (Array.isArray(targetBlock.markDefs) && targetBlock.markDefs) || []
333
+ const newMarkDefs = uniq([...oldDefs, ...op.properties.markDefs])
334
+
335
+ debug(`Copying markDefs over to merged block`, op)
336
+ Transforms.setNodes(editor, {markDefs: newMarkDefs}, {at: targetPath, voids: false})
337
+ apply(op)
338
+ return
339
+ }
340
+ }
341
+
363
342
  apply(op)
364
343
  }
365
344
 
@@ -11,7 +11,6 @@ import {type Descendant, Element, type Node, Text} from 'slate'
11
11
  import {type PortableTextMemberSchemaTypes} from '../types/editor'
12
12
 
13
13
  export const EMPTY_MARKDEFS: PortableTextObject[] = []
14
- export const EMPTY_MARKS: string[] = []
15
14
 
16
15
  export const VOID_CHILD_KEY = 'void-child'
17
16
 
@@ -1,212 +0,0 @@
1
- import {describe, expect, it, jest} from '@jest/globals'
2
- import {render, waitFor} from '@testing-library/react'
3
- import {createRef, type RefObject} from 'react'
4
-
5
- import {PortableTextEditorTester, schemaType} from '../../__tests__/PortableTextEditorTester'
6
- import {getEditableElement, triggerKeyboardEvent} from '../../__tests__/utils'
7
- import {PortableTextEditor} from '../../PortableTextEditor'
8
-
9
- const newBlock = {
10
- _type: 'myTestBlockType',
11
- _key: '3',
12
- style: 'normal',
13
- markDefs: [],
14
- children: [
15
- {
16
- _type: 'span',
17
- _key: '2',
18
- text: '',
19
- marks: [],
20
- },
21
- ],
22
- }
23
- describe('plugin:withHotkeys: .ArrowDown', () => {
24
- it('a new block is added if the user is focused on the only block which is void, and presses arrow down.', async () => {
25
- const initialValue = [
26
- {
27
- _key: 'a',
28
- _type: 'someObject',
29
- },
30
- ]
31
-
32
- const initialSelection = {
33
- focus: {path: [{_key: 'a'}], offset: 0},
34
- anchor: {path: [{_key: 'a'}], offset: 0},
35
- }
36
-
37
- const editorRef: RefObject<PortableTextEditor> = createRef()
38
- const onChange = jest.fn()
39
- const component = render(
40
- <PortableTextEditorTester
41
- onChange={onChange}
42
- ref={editorRef}
43
- schemaType={schemaType}
44
- value={initialValue}
45
- />,
46
- )
47
- const element = await getEditableElement(component)
48
-
49
- const editor = editorRef.current
50
- const inlineType = editor?.schemaTypes.inlineObjects.find((t) => t.name === 'someObject')
51
- await waitFor(async () => {
52
- if (editor && inlineType && editor) {
53
- PortableTextEditor.focus(editor)
54
- PortableTextEditor.select(editor, initialSelection)
55
- PortableTextEditor.insertBreak(editor)
56
- await triggerKeyboardEvent('ArrowDown', element)
57
-
58
- const value = PortableTextEditor.getValue(editor)
59
- expect(value).toEqual([initialValue[0], newBlock])
60
- }
61
- })
62
- })
63
- it('a new block is added if the user is focused on the last block which is void, and presses arrow down.', async () => {
64
- const initialValue = [
65
- {
66
- _type: 'myTestBlockType',
67
- _key: 'a',
68
- style: 'normal',
69
- markDefs: [],
70
- children: [
71
- {
72
- _type: 'span',
73
- _key: 'a1',
74
- text: 'This is the first block',
75
- marks: [],
76
- },
77
- ],
78
- },
79
- {
80
- _key: 'b',
81
- _type: 'someObject',
82
- },
83
- ]
84
- const initialSelection = {
85
- focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 2},
86
- anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 2},
87
- }
88
-
89
- const editorRef: RefObject<PortableTextEditor> = createRef()
90
- const onChange = jest.fn()
91
- const component = render(
92
- <PortableTextEditorTester
93
- onChange={onChange}
94
- ref={editorRef}
95
- schemaType={schemaType}
96
- value={initialValue}
97
- />,
98
- )
99
- const element = await getEditableElement(component)
100
-
101
- const editor = editorRef.current
102
- const inlineType = editor?.schemaTypes.inlineObjects.find((t) => t.name === 'someObject')
103
- await waitFor(async () => {
104
- if (editor && inlineType && element) {
105
- PortableTextEditor.focus(editor)
106
- PortableTextEditor.select(editor, initialSelection)
107
- await triggerKeyboardEvent('ArrowDown', element)
108
- const value = PortableTextEditor.getValue(editor)
109
- // Arrow down on the text block should not add a new block
110
- expect(value).toEqual(initialValue)
111
- // Focus on the object block
112
- PortableTextEditor.select(editor, {
113
- focus: {path: [{_key: 'b'}], offset: 0},
114
- anchor: {path: [{_key: 'b'}], offset: 0},
115
- })
116
- await triggerKeyboardEvent('ArrowDown', element)
117
- const value2 = PortableTextEditor.getValue(editor)
118
- expect(value2).toEqual([
119
- initialValue[0],
120
- initialValue[1],
121
- {
122
- _type: 'myTestBlockType',
123
- _key: '3',
124
- style: 'normal',
125
- markDefs: [],
126
- children: [
127
- {
128
- _type: 'span',
129
- _key: '2',
130
- text: '',
131
- marks: [],
132
- },
133
- ],
134
- },
135
- ])
136
- }
137
- })
138
- })
139
- })
140
- describe('plugin:withHotkeys: .ArrowUp', () => {
141
- it('a new block is added at the top, when pressing arrow up, because first block is void, the new block can be deleted with backspace.', async () => {
142
- const initialValue = [
143
- {
144
- _key: 'b',
145
- _type: 'someObject',
146
- },
147
- {
148
- _type: 'myTestBlockType',
149
- _key: 'a',
150
- style: 'normal',
151
- markDefs: [],
152
- children: [
153
- {
154
- _type: 'span',
155
- _key: 'a1',
156
- text: 'This is the first block',
157
- marks: [],
158
- },
159
- ],
160
- },
161
- ]
162
-
163
- const initialSelection = {
164
- focus: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 2},
165
- anchor: {path: [{_key: 'a'}, 'children', {_key: 'a1'}], offset: 2},
166
- }
167
-
168
- const editorRef: RefObject<PortableTextEditor> = createRef()
169
- const onChange = jest.fn()
170
- const component = render(
171
- <PortableTextEditorTester
172
- onChange={onChange}
173
- ref={editorRef}
174
- schemaType={schemaType}
175
- value={initialValue}
176
- />,
177
- )
178
- const element = await getEditableElement(component)
179
-
180
- const editor = editorRef.current
181
- const inlineType = editor?.schemaTypes.inlineObjects.find((t) => t.name === 'someObject')
182
- await waitFor(async () => {
183
- if (editor && inlineType && element) {
184
- PortableTextEditor.focus(editor)
185
- PortableTextEditor.select(editor, initialSelection)
186
- await triggerKeyboardEvent('ArrowUp', element)
187
- // Arrow down on the text block should not add a new block
188
- expect(PortableTextEditor.getValue(editor)).toEqual(initialValue)
189
- // Focus on the object block
190
- PortableTextEditor.select(editor, {
191
- focus: {path: [{_key: 'b'}], offset: 0},
192
- anchor: {path: [{_key: 'b'}], offset: 0},
193
- })
194
- await triggerKeyboardEvent('ArrowUp', element)
195
- expect(PortableTextEditor.getValue(editor)).toEqual([
196
- newBlock,
197
- initialValue[0],
198
- initialValue[1],
199
- ])
200
- // Pressing arrow up again won't add a new block
201
- await triggerKeyboardEvent('ArrowUp', element)
202
- expect(PortableTextEditor.getValue(editor)).toEqual([
203
- newBlock,
204
- initialValue[0],
205
- initialValue[1],
206
- ])
207
- await triggerKeyboardEvent('Backspace', element)
208
- expect(PortableTextEditor.getValue(editor)).toEqual(initialValue)
209
- }
210
- })
211
- })
212
- })