@portabletext/editor 1.23.0 → 1.24.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 (53) hide show
  1. package/lib/_chunks-cjs/behavior.core.cjs +65 -2
  2. package/lib/_chunks-cjs/behavior.core.cjs.map +1 -1
  3. package/lib/_chunks-cjs/util.slice-blocks.cjs +23 -9
  4. package/lib/_chunks-cjs/util.slice-blocks.cjs.map +1 -1
  5. package/lib/_chunks-es/behavior.core.js +65 -2
  6. package/lib/_chunks-es/behavior.core.js.map +1 -1
  7. package/lib/_chunks-es/util.slice-blocks.js +23 -9
  8. package/lib/_chunks-es/util.slice-blocks.js.map +1 -1
  9. package/lib/behaviors/index.d.cts +1111 -44
  10. package/lib/behaviors/index.d.ts +1111 -44
  11. package/lib/index.cjs +535 -333
  12. package/lib/index.cjs.map +1 -1
  13. package/lib/index.d.cts +158 -1
  14. package/lib/index.d.ts +158 -1
  15. package/lib/index.js +539 -335
  16. package/lib/index.js.map +1 -1
  17. package/lib/selectors/index.d.cts +73 -0
  18. package/lib/selectors/index.d.ts +73 -0
  19. package/package.json +11 -10
  20. package/src/behavior-actions/behavior.action.data-transfer-set.ts +7 -0
  21. package/src/behavior-actions/behavior.action.insert-blocks.ts +61 -0
  22. package/src/behavior-actions/behavior.actions.ts +75 -0
  23. package/src/behaviors/behavior.core.deserialize.ts +46 -0
  24. package/src/behaviors/behavior.core.serialize.ts +44 -0
  25. package/src/behaviors/behavior.core.ts +7 -0
  26. package/src/behaviors/behavior.types.ts +39 -2
  27. package/src/converters/converter.json.ts +53 -0
  28. package/src/converters/converter.portable-text.deserialize.test.ts +686 -0
  29. package/src/converters/converter.portable-text.ts +59 -0
  30. package/src/converters/converter.text-html.deserialize.test.ts +349 -0
  31. package/src/converters/converter.text-html.serialize.test.ts +233 -0
  32. package/src/converters/converter.text-html.ts +61 -0
  33. package/src/converters/converter.text-plain.test.ts +241 -0
  34. package/src/converters/converter.text-plain.ts +91 -0
  35. package/src/converters/converter.ts +65 -0
  36. package/src/converters/converters.ts +11 -0
  37. package/src/editor/Editable.tsx +3 -13
  38. package/src/editor/create-editor.ts +2 -0
  39. package/src/editor/editor-machine.ts +18 -1
  40. package/src/editor/editor-selector.ts +1 -0
  41. package/src/editor/editor-snapshot.ts +5 -0
  42. package/src/editor/plugins/create-with-event-listeners.ts +44 -0
  43. package/src/internal-utils/asserters.ts +9 -0
  44. package/src/internal-utils/mime-type.ts +1 -0
  45. package/src/internal-utils/parse-blocks.ts +136 -0
  46. package/src/internal-utils/test-key-generator.ts +9 -0
  47. package/src/selectors/selector.get-selected-spans.test.ts +1 -0
  48. package/src/selectors/selector.get-selection-text.test.ts +1 -0
  49. package/src/selectors/selector.is-active-decorator.test.ts +1 -0
  50. package/src/utils/util.slice-blocks.test.ts +87 -0
  51. package/src/utils/util.slice-blocks.ts +27 -10
  52. package/src/editor/plugins/__tests__/createWithInsertData.test.tsx +0 -181
  53. package/src/editor/plugins/createWithInsertData.ts +0 -425
@@ -1,425 +0,0 @@
1
- import {htmlToBlocks} from '@portabletext/block-tools'
2
- import type {PortableTextBlock, PortableTextChild} from '@sanity/types'
3
- import {isEqual, uniq} from 'lodash'
4
- import {Editor, Range, Transforms, type Descendant, type Node} from 'slate'
5
- import {ReactEditor} from 'slate-react'
6
- import {debugWithName} from '../../internal-utils/debug'
7
- import {validateValue} from '../../internal-utils/validateValue'
8
- import {
9
- fromSlateValue,
10
- isEqualToEmptyEditor,
11
- toSlateValue,
12
- } from '../../internal-utils/values'
13
- import type {
14
- PortableTextMemberSchemaTypes,
15
- PortableTextSlateEditor,
16
- } from '../../types/editor'
17
- import type {EditorActor} from '../editor-machine'
18
-
19
- const debug = debugWithName('plugin:withInsertData')
20
-
21
- /**
22
- * This plugin handles copy/paste in the editor
23
- *
24
- */
25
- export function createWithInsertData(
26
- editorActor: EditorActor,
27
- schemaTypes: PortableTextMemberSchemaTypes,
28
- ) {
29
- return function withInsertData(
30
- editor: PortableTextSlateEditor,
31
- ): PortableTextSlateEditor {
32
- const blockTypeName = schemaTypes.block.name
33
- const spanTypeName = schemaTypes.span.name
34
- const whitespaceOnPasteMode =
35
- schemaTypes.block.options.unstable_whitespaceOnPasteMode
36
-
37
- const toPlainText = (blocks: PortableTextBlock[]) => {
38
- return blocks
39
- .map((block) => {
40
- if (editor.isTextBlock(block)) {
41
- return block.children
42
- .map((child: PortableTextChild) => {
43
- if (child._type === spanTypeName) {
44
- return child.text
45
- }
46
- return `[${
47
- schemaTypes.inlineObjects.find((t) => t.name === child._type)
48
- ?.title || 'Object'
49
- }]`
50
- })
51
- .join('')
52
- }
53
- return `[${
54
- schemaTypes.blockObjects.find((t) => t.name === block._type)
55
- ?.title || 'Object'
56
- }]`
57
- })
58
- .join('\n\n')
59
- }
60
-
61
- editor.setFragmentData = (data: DataTransfer, originEvent) => {
62
- const {selection} = editor
63
-
64
- if (!selection) {
65
- return
66
- }
67
-
68
- const [start, end] = Range.edges(selection)
69
- const startVoid = Editor.void(editor, {at: start.path})
70
- const endVoid = Editor.void(editor, {at: end.path})
71
-
72
- if (Range.isCollapsed(selection) && !startVoid) {
73
- return
74
- }
75
-
76
- // Create a fake selection so that we can add a Base64-encoded copy of the
77
- // fragment to the HTML, to decode on future pastes.
78
- const domRange = ReactEditor.toDOMRange(editor, selection)
79
- let contents = domRange.cloneContents()
80
- // COMPAT: If the end node is a void node, we need to move the end of the
81
- // range from the void node's spacer span, to the end of the void node's
82
- // content, since the spacer is before void's content in the DOM.
83
- if (endVoid) {
84
- const [voidNode] = endVoid
85
- const r = domRange.cloneRange()
86
- const domNode = ReactEditor.toDOMNode(editor, voidNode)
87
- r.setEndAfter(domNode)
88
- contents = r.cloneContents()
89
- }
90
- // Remove any zero-width space spans from the cloned DOM so that they don't
91
- // show up elsewhere when pasted.
92
- Array.from(contents.querySelectorAll('[data-slate-zero-width]')).forEach(
93
- (zw) => {
94
- const isNewline = zw.getAttribute('data-slate-zero-width') === 'n'
95
- zw.textContent = isNewline ? '\n' : ''
96
- },
97
- )
98
- // Clean up the clipboard HTML for editor spesific attributes
99
- Array.from(contents.querySelectorAll('*')).forEach((elm) => {
100
- elm.removeAttribute('contentEditable')
101
- elm.removeAttribute('data-slate-inline')
102
- elm.removeAttribute('data-slate-leaf')
103
- elm.removeAttribute('data-slate-node')
104
- elm.removeAttribute('data-slate-spacer')
105
- elm.removeAttribute('data-slate-string')
106
- elm.removeAttribute('data-slate-zero-width')
107
- elm.removeAttribute('draggable')
108
- for (const key in elm.attributes) {
109
- if (elm.hasAttribute(key)) {
110
- elm.removeAttribute(key)
111
- }
112
- }
113
- })
114
- const div = contents.ownerDocument.createElement('div')
115
- div.appendChild(contents)
116
- div.setAttribute('hidden', 'true')
117
- contents.ownerDocument.body.appendChild(div)
118
- const asHTML = div.innerHTML
119
- contents.ownerDocument.body.removeChild(div)
120
- const fragment = editor.getFragment()
121
- const portableText = fromSlateValue(fragment, blockTypeName)
122
-
123
- const asJSON = JSON.stringify(portableText)
124
- const asPlainText = toPlainText(portableText)
125
- data.clearData()
126
- data.setData('text/plain', asPlainText)
127
- data.setData('text/html', asHTML)
128
- data.setData('application/json', asJSON)
129
- data.setData('application/x-portable-text', asJSON)
130
- debug('text', asPlainText)
131
- data.setData(
132
- 'application/x-portable-text-event-origin',
133
- originEvent || 'external',
134
- )
135
- debug('Set fragment data', asJSON, asHTML)
136
- }
137
-
138
- editor.insertPortableTextData = (data: DataTransfer): boolean => {
139
- if (!editor.selection) {
140
- return false
141
- }
142
- const pText = data.getData('application/x-portable-text')
143
- const origin = data.getData('application/x-portable-text-event-origin')
144
- debug(`Inserting portable text from ${origin} event`, pText)
145
- if (pText) {
146
- const parsed = JSON.parse(pText) as PortableTextBlock[]
147
- if (Array.isArray(parsed) && parsed.length > 0) {
148
- const slateValue = _regenerateKeys(
149
- editor,
150
- toSlateValue(parsed, {schemaTypes}),
151
- editorActor.getSnapshot().context.keyGenerator,
152
- spanTypeName,
153
- schemaTypes,
154
- )
155
- // Validate the result
156
- const validation = validateValue(
157
- parsed,
158
- schemaTypes,
159
- editorActor.getSnapshot().context.keyGenerator,
160
- )
161
- // Bail out if it's not valid
162
- if (!validation.valid && !validation.resolution?.autoResolve) {
163
- const errorDescription = `${validation.resolution?.description}`
164
- editorActor.send({
165
- type: 'error',
166
- name: 'pasteError',
167
- description: errorDescription,
168
- data: validation,
169
- })
170
- debug('Invalid insert result', validation)
171
- return false
172
- }
173
- _insertFragment(editor, slateValue, schemaTypes)
174
- return true
175
- }
176
- }
177
- return false
178
- }
179
-
180
- editor.insertTextOrHTMLData = (data: DataTransfer): boolean => {
181
- if (!editor.selection) {
182
- debug('No selection, not inserting')
183
- return false
184
- }
185
- const html = data.getData('text/html')
186
- const text = data.getData('text/plain')
187
-
188
- if (html || text) {
189
- debug('Inserting data', data)
190
- let portableText: PortableTextBlock[]
191
- let fragment: Node[]
192
- let insertedType: string | undefined
193
-
194
- if (html) {
195
- portableText = htmlToBlocks(html, schemaTypes.portableText, {
196
- unstable_whitespaceOnPasteMode: whitespaceOnPasteMode,
197
- keyGenerator: editorActor.getSnapshot().context.keyGenerator,
198
- }) as PortableTextBlock[]
199
- fragment = toSlateValue(portableText, {schemaTypes})
200
- insertedType = 'HTML'
201
-
202
- if (portableText.length === 0) {
203
- return false
204
- }
205
- } else {
206
- // plain text
207
- const blocks = escapeHtml(text)
208
- .split(/\n{2,}/)
209
- .map((line) =>
210
- line
211
- ? `<p>${line.replace(/(?:\r\n|\r|\n)/g, '<br/>')}</p>`
212
- : '<p></p>',
213
- )
214
- .join('')
215
- const textToHtml = `<html><body>${blocks}</body></html>`
216
- portableText = htmlToBlocks(textToHtml, schemaTypes.portableText, {
217
- keyGenerator: editorActor.getSnapshot().context.keyGenerator,
218
- }) as PortableTextBlock[]
219
- fragment = toSlateValue(portableText, {
220
- schemaTypes,
221
- })
222
- insertedType = 'text'
223
- }
224
-
225
- // Validate the result
226
- const validation = validateValue(
227
- portableText,
228
- schemaTypes,
229
- editorActor.getSnapshot().context.keyGenerator,
230
- )
231
-
232
- // Bail out if it's not valid
233
- if (!validation.valid) {
234
- const errorDescription = `Could not validate the resulting portable text to insert.\n${validation.resolution?.description}\nTry to insert as plain text (shift-paste) instead.`
235
- editorActor.send({
236
- type: 'error',
237
- name: 'pasteError',
238
- description: errorDescription,
239
- data: validation,
240
- })
241
- debug('Invalid insert result', validation)
242
- return false
243
- }
244
- debug(
245
- `Inserting ${insertedType} fragment at ${JSON.stringify(editor.selection)}`,
246
- )
247
- _insertFragment(editor, fragment, schemaTypes)
248
- return true
249
- }
250
- return false
251
- }
252
-
253
- editor.insertData = (data: DataTransfer) => {
254
- if (!editor.insertPortableTextData(data)) {
255
- editor.insertTextOrHTMLData(data)
256
- }
257
- }
258
-
259
- editor.insertFragmentData = (data: DataTransfer): boolean => {
260
- const fragment = data.getData('application/x-portable-text')
261
- if (fragment) {
262
- const parsed = JSON.parse(fragment)
263
- editor.insertFragment(parsed)
264
- return true
265
- }
266
- return false
267
- }
268
-
269
- return editor
270
- }
271
- }
272
-
273
- const entityMap: Record<string, string> = {
274
- '&': '&amp;',
275
- '<': '&lt;',
276
- '>': '&gt;',
277
- '"': '&quot;',
278
- "'": '&#39;',
279
- '/': '&#x2F;',
280
- '`': '&#x60;',
281
- '=': '&#x3D;',
282
- }
283
- function escapeHtml(str: string) {
284
- return String(str).replace(/[&<>"'`=/]/g, (s: string) => entityMap[s])
285
- }
286
-
287
- /**
288
- * Shared helper function to regenerate the keys on a fragment.
289
- *
290
- * @internal
291
- */
292
- function _regenerateKeys(
293
- editor: Pick<PortableTextSlateEditor, 'isTextBlock' | 'isTextSpan'>,
294
- fragment: Descendant[],
295
- keyGenerator: () => string,
296
- spanTypeName: string,
297
- editorTypes: Pick<PortableTextMemberSchemaTypes, 'annotations'>,
298
- ): Descendant[] {
299
- return fragment.map((node) => {
300
- const newNode: Descendant = {...node}
301
- // Ensure the copy has new keys
302
- if (editor.isTextBlock(newNode)) {
303
- const annotations = editorTypes.annotations.map((t) => t.name)
304
-
305
- // Ensure that if there are no annotations, we remove the markDefs
306
- if (annotations.length === 0) {
307
- const {markDefs, ...NewNodeNoDefs} = newNode
308
-
309
- return {...NewNodeNoDefs, _key: keyGenerator()}
310
- }
311
-
312
- // Ensure that all annotations are allowed
313
- const hasForbiddenAnnotations = (newNode.markDefs || []).some((def) => {
314
- return !annotations.includes(def._type)
315
- })
316
-
317
- // if they have forbidden annotations, we remove them and keep the rest
318
- if (hasForbiddenAnnotations) {
319
- const allowedAnnotations = (newNode.markDefs || []).filter((def) => {
320
- return annotations.includes(def._type)
321
- })
322
-
323
- return {...newNode, markDefs: allowedAnnotations, _key: keyGenerator()}
324
- }
325
-
326
- newNode.markDefs = (newNode.markDefs || []).map((def) => {
327
- const oldKey = def._key
328
- const newKey = keyGenerator()
329
- newNode.children = newNode.children.map((child) =>
330
- child._type === spanTypeName && editor.isTextSpan(child)
331
- ? {
332
- ...child,
333
- marks:
334
- child.marks && child.marks.includes(oldKey)
335
- ? [...child.marks]
336
- .filter((mark) => mark !== oldKey)
337
- .concat(newKey)
338
- : child.marks,
339
- }
340
- : child,
341
- )
342
- return {...def, _key: newKey}
343
- })
344
- }
345
- const nodeWithNewKeys = {...newNode, _key: keyGenerator()}
346
- if (editor.isTextBlock(nodeWithNewKeys)) {
347
- nodeWithNewKeys.children = nodeWithNewKeys.children.map((child) => ({
348
- ...child,
349
- _key: keyGenerator(),
350
- }))
351
- }
352
- return nodeWithNewKeys as Descendant
353
- })
354
- }
355
-
356
- /**
357
- * Shared helper function to insert the final fragment into the editor
358
- *
359
- * @internal
360
- */
361
- function _insertFragment(
362
- editor: PortableTextSlateEditor,
363
- fragment: Descendant[],
364
- schemaTypes: PortableTextMemberSchemaTypes,
365
- ) {
366
- editor.withoutNormalizing(() => {
367
- if (!editor.selection) {
368
- return
369
- }
370
- // Ensure that markDefs for any annotations inside this fragment are copied over to the focused text block.
371
- const [focusBlock, focusPath] = Editor.node(editor, editor.selection, {
372
- depth: 1,
373
- })
374
- if (editor.isTextBlock(focusBlock) && editor.isTextBlock(fragment[0])) {
375
- const {markDefs} = focusBlock
376
- debug(
377
- 'Mixing markDefs of focusBlock and fragments[0] block',
378
- markDefs,
379
- fragment[0].markDefs,
380
- )
381
- if (!isEqual(markDefs, fragment[0].markDefs)) {
382
- Transforms.setNodes(
383
- editor,
384
- {
385
- markDefs: uniq([
386
- ...(fragment[0].markDefs || []),
387
- ...(markDefs || []),
388
- ]),
389
- },
390
- {at: focusPath, mode: 'lowest', voids: false},
391
- )
392
- }
393
- }
394
-
395
- const isPasteToEmptyEditor = isEqualToEmptyEditor(
396
- editor.children,
397
- schemaTypes,
398
- )
399
-
400
- if (isPasteToEmptyEditor) {
401
- // Special case for pasting directly into an empty editor (a placeholder block).
402
- // When pasting content starting with multiple empty blocks,
403
- // `editor.insertFragment` can potentially duplicate the keys of
404
- // the placeholder block because of operations that happen
405
- // inside `editor.insertFragment` (involves an `insert_node` operation).
406
- // However by splitting the placeholder block first in this situation we are good.
407
- Transforms.splitNodes(editor, {at: [0, 0]})
408
- editor.insertFragment(fragment)
409
- Transforms.removeNodes(editor, {at: [0]})
410
- } else {
411
- // All other inserts
412
- editor.insertFragment(fragment)
413
- }
414
- })
415
-
416
- editor.onChange()
417
- }
418
-
419
- /**
420
- * functions we don't want to export but want to test
421
- * @internal
422
- */
423
- export const exportedForTesting = {
424
- _regenerateKeys,
425
- }