@portabletext/editor 1.44.12 → 1.44.14

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": "1.44.12",
3
+ "version": "1.44.14",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -93,10 +93,10 @@
93
93
  "@types/debug": "^4.1.12",
94
94
  "@types/lodash": "^4.17.13",
95
95
  "@types/lodash.startcase": "^4.4.9",
96
- "@types/react": "^19.0.12",
97
- "@types/react-dom": "^19.0.4",
98
- "@typescript-eslint/eslint-plugin": "^8.26.1",
99
- "@typescript-eslint/parser": "^8.26.1",
96
+ "@types/react": "^19.1.0",
97
+ "@types/react-dom": "^19.1.1",
98
+ "@typescript-eslint/eslint-plugin": "^8.29.0",
99
+ "@typescript-eslint/parser": "^8.29.0",
100
100
  "@vitejs/plugin-react": "^4.3.4",
101
101
  "@vitest/browser": "^3.1.1",
102
102
  "@vitest/coverage-istanbul": "^3.1.1",
@@ -109,7 +109,7 @@
109
109
  "react-dom": "^19.1.0",
110
110
  "rxjs": "^7.8.2",
111
111
  "typescript": "5.8.2",
112
- "vite": "^6.2.0",
112
+ "vite": "^6.2.5",
113
113
  "vitest": "^3.1.1",
114
114
  "vitest-browser-react": "^0.1.1",
115
115
  "racejar": "1.2.3"
@@ -0,0 +1,133 @@
1
+ import type {Path} from '@sanity/types'
2
+ import {Editor, Node, Range, Text, Transforms} from 'slate'
3
+ import type {BehaviorActionImplementation} from './behavior.actions'
4
+
5
+ /**
6
+ * @public
7
+ */
8
+ export type AddedAnnotationPaths = {
9
+ /**
10
+ * @deprecated An annotation may be applied to multiple blocks, resulting
11
+ * in multiple `markDef`'s being created. Use `markDefPaths` instead.
12
+ */
13
+ markDefPath: Path
14
+ markDefPaths: Array<Path>
15
+ /**
16
+ * @deprecated Does not return anything meaningful since an annotation
17
+ * can span multiple blocks and spans. If references the span closest
18
+ * to the focus point of the selection.
19
+ */
20
+ spanPath: Path
21
+ }
22
+
23
+ export const addAnnotationActionImplementation: BehaviorActionImplementation<
24
+ 'annotation.add',
25
+ AddedAnnotationPaths | undefined
26
+ > = ({context, action}) => {
27
+ const editor = action.editor
28
+
29
+ if (!editor.selection || Range.isCollapsed(editor.selection)) {
30
+ return
31
+ }
32
+
33
+ let paths: AddedAnnotationPaths | undefined = undefined
34
+ let spanPath: Path | undefined
35
+ let markDefPath: Path | undefined
36
+ const markDefPaths: Path[] = []
37
+
38
+ const selectedBlocks = Editor.nodes(editor, {
39
+ at: editor.selection,
40
+ match: (node) => editor.isTextBlock(node),
41
+ reverse: Range.isBackward(editor.selection),
42
+ })
43
+
44
+ for (const [block, blockPath] of selectedBlocks) {
45
+ if (block.children.length === 0) {
46
+ continue
47
+ }
48
+
49
+ if (block.children.length === 1 && block.children[0].text === '') {
50
+ continue
51
+ }
52
+
53
+ const annotationKey = context.keyGenerator()
54
+ const markDefs = block.markDefs ?? []
55
+ const existingMarkDef = markDefs.find(
56
+ (markDef) =>
57
+ markDef._type === action.annotation.name &&
58
+ markDef._key === annotationKey,
59
+ )
60
+
61
+ if (existingMarkDef === undefined) {
62
+ Transforms.setNodes(
63
+ editor,
64
+ {
65
+ markDefs: [
66
+ ...markDefs,
67
+ {
68
+ _type: action.annotation.name,
69
+ _key: annotationKey,
70
+ ...action.annotation.value,
71
+ },
72
+ ],
73
+ },
74
+ {at: blockPath},
75
+ )
76
+
77
+ markDefPath = [{_key: block._key}, 'markDefs', {_key: annotationKey}]
78
+
79
+ if (Range.isBackward(editor.selection)) {
80
+ markDefPaths.unshift(markDefPath)
81
+ } else {
82
+ markDefPaths.push(markDefPath)
83
+ }
84
+ }
85
+
86
+ Transforms.setNodes(editor, {}, {match: Text.isText, split: true})
87
+
88
+ const children = Node.children(editor, blockPath)
89
+
90
+ for (const [span, path] of children) {
91
+ if (!editor.isTextSpan(span)) {
92
+ continue
93
+ }
94
+
95
+ if (!Range.includes(editor.selection, path)) {
96
+ continue
97
+ }
98
+
99
+ const marks = span.marks ?? []
100
+ const existingSameTypeAnnotations = marks.filter((mark) =>
101
+ markDefs.some(
102
+ (markDef) =>
103
+ markDef._key === mark && markDef._type === action.annotation.name,
104
+ ),
105
+ )
106
+
107
+ Transforms.setNodes(
108
+ editor,
109
+ {
110
+ marks: [
111
+ ...marks.filter(
112
+ (mark) => !existingSameTypeAnnotations.includes(mark),
113
+ ),
114
+ annotationKey,
115
+ ],
116
+ },
117
+ {at: path},
118
+ )
119
+
120
+ spanPath = [{_key: block._key}, 'children', {_key: span._key}]
121
+ }
122
+ }
123
+
124
+ if (markDefPath && spanPath) {
125
+ paths = {
126
+ markDefPath,
127
+ markDefPaths,
128
+ spanPath,
129
+ }
130
+ }
131
+
132
+ return paths
133
+ }
@@ -0,0 +1,150 @@
1
+ import type {PortableTextSpan} from '@sanity/types'
2
+ import {Editor, Node, Path, Range, Transforms} from 'slate'
3
+ import type {BehaviorActionImplementation} from './behavior.actions'
4
+
5
+ export const removeAnnotationActionImplementation: BehaviorActionImplementation<
6
+ 'annotation.remove'
7
+ > = ({action}) => {
8
+ const editor = action.editor
9
+
10
+ if (!editor.selection) {
11
+ return
12
+ }
13
+
14
+ if (Range.isCollapsed(editor.selection)) {
15
+ const [block, blockPath] = Editor.node(editor, editor.selection, {
16
+ depth: 1,
17
+ })
18
+
19
+ if (!editor.isTextBlock(block)) {
20
+ return
21
+ }
22
+
23
+ const markDefs = block.markDefs ?? []
24
+ const potentialAnnotations = markDefs.filter(
25
+ (markDef) => markDef._type === action.annotation.name,
26
+ )
27
+
28
+ const [selectedChild, selectedChildPath] = Editor.node(
29
+ editor,
30
+ editor.selection,
31
+ {
32
+ depth: 2,
33
+ },
34
+ )
35
+
36
+ if (!editor.isTextSpan(selectedChild)) {
37
+ return
38
+ }
39
+
40
+ const annotationToRemove = selectedChild.marks?.find((mark) =>
41
+ potentialAnnotations.some((markDef) => markDef._key === mark),
42
+ )
43
+
44
+ if (!annotationToRemove) {
45
+ return
46
+ }
47
+
48
+ const previousSpansWithSameAnnotation: Array<
49
+ [span: PortableTextSpan, path: Path]
50
+ > = []
51
+
52
+ for (const [child, childPath] of Node.children(editor, blockPath, {
53
+ reverse: true,
54
+ })) {
55
+ if (!editor.isTextSpan(child)) {
56
+ continue
57
+ }
58
+
59
+ if (!Path.isBefore(childPath, selectedChildPath)) {
60
+ continue
61
+ }
62
+
63
+ if (child.marks?.includes(annotationToRemove)) {
64
+ previousSpansWithSameAnnotation.push([child, childPath])
65
+ } else {
66
+ break
67
+ }
68
+ }
69
+
70
+ const nextSpansWithSameAnnotation: Array<
71
+ [span: PortableTextSpan, path: Path]
72
+ > = []
73
+
74
+ for (const [child, childPath] of Node.children(editor, blockPath)) {
75
+ if (!editor.isTextSpan(child)) {
76
+ continue
77
+ }
78
+
79
+ if (!Path.isAfter(childPath, selectedChildPath)) {
80
+ continue
81
+ }
82
+
83
+ if (child.marks?.includes(annotationToRemove)) {
84
+ nextSpansWithSameAnnotation.push([child, childPath])
85
+ } else {
86
+ break
87
+ }
88
+ }
89
+
90
+ for (const [child, childPath] of [
91
+ ...previousSpansWithSameAnnotation,
92
+ [selectedChild, selectedChildPath] as const,
93
+ ...nextSpansWithSameAnnotation,
94
+ ]) {
95
+ Transforms.setNodes(
96
+ editor,
97
+ {
98
+ marks: child.marks?.filter((mark) => mark !== annotationToRemove),
99
+ },
100
+ {at: childPath},
101
+ )
102
+ }
103
+ } else {
104
+ Transforms.setNodes(
105
+ editor,
106
+ {},
107
+ {
108
+ match: (node) => editor.isTextSpan(node),
109
+ split: true,
110
+ hanging: true,
111
+ },
112
+ )
113
+
114
+ const blocks = Editor.nodes(editor, {
115
+ at: editor.selection,
116
+ match: (node) => editor.isTextBlock(node),
117
+ })
118
+
119
+ for (const [block, blockPath] of blocks) {
120
+ const children = Node.children(editor, blockPath)
121
+
122
+ for (const [child, childPath] of children) {
123
+ if (!editor.isTextSpan(child)) {
124
+ continue
125
+ }
126
+
127
+ if (!Range.includes(editor.selection, childPath)) {
128
+ continue
129
+ }
130
+
131
+ const markDefs = block.markDefs ?? []
132
+ const marks = child.marks ?? []
133
+ const marksWithoutAnnotation = marks.filter((mark) => {
134
+ const markDef = markDefs.find((markDef) => markDef._key === mark)
135
+ return markDef?._type !== action.annotation.name
136
+ })
137
+
138
+ if (marksWithoutAnnotation.length !== marks.length) {
139
+ Transforms.setNodes(
140
+ editor,
141
+ {
142
+ marks: marksWithoutAnnotation,
143
+ },
144
+ {at: childPath},
145
+ )
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
@@ -1,10 +1,6 @@
1
1
  import {omit} from 'lodash'
2
2
  import type {InternalBehaviorAction} from '../behaviors/behavior.types.action'
3
3
  import type {EditorContext} from '../editor/editor-snapshot'
4
- import {
5
- addAnnotationActionImplementation,
6
- removeAnnotationActionImplementation,
7
- } from '../editor/plugins/createWithEditableAPI'
8
4
  import {removeDecoratorActionImplementation} from '../editor/plugins/createWithPortableTextMarkModel'
9
5
  import {
10
6
  historyRedoActionImplementation,
@@ -12,6 +8,8 @@ import {
12
8
  } from '../editor/plugins/createWithUndoRedo'
13
9
  import {debugWithName} from '../internal-utils/debug'
14
10
  import type {PickFromUnion} from '../type-utils'
11
+ import {addAnnotationActionImplementation} from './behavior.action.annotation.add'
12
+ import {removeAnnotationActionImplementation} from './behavior.action.annotation.remove'
15
13
  import {blockSetBehaviorActionImplementation} from './behavior.action.block.set'
16
14
  import {blockUnsetBehaviorActionImplementation} from './behavior.action.block.unset'
17
15
  import {blurActionImplementation} from './behavior.action.blur'
@@ -1,6 +1,7 @@
1
1
  import {htmlToBlocks} from '@portabletext/block-tools'
2
2
  import {toHTML} from '@portabletext/to-html'
3
3
  import type {PortableTextBlock} from '@sanity/types'
4
+ import {parseBlock} from '../internal-utils/parse-blocks'
4
5
  import {sliceBlocks} from '../utils'
5
6
  import {defineConverter} from './converter.types'
6
7
 
@@ -59,7 +60,18 @@ export const converterTextHtml = defineConverter({
59
60
  },
60
61
  ) as Array<PortableTextBlock>
61
62
 
62
- if (blocks.length === 0) {
63
+ const parsedBlocks = blocks.flatMap((block) => {
64
+ const parsedBlock = parseBlock({
65
+ context: snapshot.context,
66
+ block,
67
+ options: {
68
+ refreshKeys: false,
69
+ },
70
+ })
71
+ return parsedBlock ? [parsedBlock] : []
72
+ })
73
+
74
+ if (parsedBlocks.length === 0) {
63
75
  return {
64
76
  type: 'deserialization.failure',
65
77
  mimeType: 'text/html',
@@ -69,7 +81,7 @@ export const converterTextHtml = defineConverter({
69
81
 
70
82
  return {
71
83
  type: 'deserialization.success',
72
- data: blocks,
84
+ data: parsedBlocks,
73
85
  mimeType: 'text/html',
74
86
  }
75
87
  },
@@ -1,5 +1,6 @@
1
1
  import {htmlToBlocks} from '@portabletext/block-tools'
2
2
  import {isPortableTextTextBlock, type PortableTextBlock} from '@sanity/types'
3
+ import {parseBlock} from '../internal-utils/parse-blocks'
3
4
  import {sliceBlocks} from '../utils'
4
5
  import {defineConverter} from './converter.types'
5
6
 
@@ -80,7 +81,18 @@ export const converterTextPlain = defineConverter({
80
81
  },
81
82
  ) as Array<PortableTextBlock>
82
83
 
83
- if (blocks.length === 0) {
84
+ const parsedBlocks = blocks.flatMap((block) => {
85
+ const parsedBlock = parseBlock({
86
+ context: snapshot.context,
87
+ block,
88
+ options: {
89
+ refreshKeys: false,
90
+ },
91
+ })
92
+ return parsedBlock ? [parsedBlock] : []
93
+ })
94
+
95
+ if (parsedBlocks.length === 0) {
84
96
  return {
85
97
  type: 'deserialization.failure',
86
98
  mimeType: 'text/plain',
@@ -90,7 +102,7 @@ export const converterTextPlain = defineConverter({
90
102
 
91
103
  return {
92
104
  type: 'deserialization.success',
93
- data: blocks,
105
+ data: parsedBlocks,
94
106
  mimeType: 'text/plain',
95
107
  }
96
108
  },
@@ -15,6 +15,7 @@ import {
15
15
  import {Subject} from 'rxjs'
16
16
  import {Slate} from 'slate-react'
17
17
  import {useEffectEvent} from 'use-effect-event'
18
+ import type {AddedAnnotationPaths} from '../behavior-actions/behavior.action.annotation.add'
18
19
  import {debugWithName} from '../internal-utils/debug'
19
20
  import {compileType} from '../internal-utils/schema'
20
21
  import type {
@@ -34,7 +35,6 @@ import type {EditorActor} from './editor-machine'
34
35
  import {PortableTextEditorContext} from './hooks/usePortableTextEditor'
35
36
  import {PortableTextEditorSelectionProvider} from './hooks/usePortableTextEditorSelection'
36
37
  import {defaultKeyGenerator} from './key-generator'
37
- import type {AddedAnnotationPaths} from './plugins/createWithEditableAPI'
38
38
 
39
39
  const debug = debugWithName('component:PortableTextEditor')
40
40
 
@@ -3,6 +3,7 @@ import {defineField, defineType, type ObjectSchemaType} from '@sanity/types'
3
3
  import startCase from 'lodash.startcase'
4
4
  import type {PortableTextMemberSchemaTypes} from '../types/editor'
5
5
  import {createEditorSchema} from './create-editor-schema'
6
+ import {defaultKeyGenerator} from './key-generator'
6
7
 
7
8
  /**
8
9
  * @public
@@ -55,6 +56,24 @@ export function defineSchema<const TSchemaDefinition extends SchemaDefinition>(
55
56
  return definition
56
57
  }
57
58
 
59
+ const temporaryImageName = `tmp-${defaultKeyGenerator()}-image`
60
+ const temporaryUrlName = `tmp-${defaultKeyGenerator()}-url`
61
+
62
+ const temporaryObjectNames: Record<string, string> = {
63
+ image: temporaryImageName,
64
+ url: temporaryUrlName,
65
+ }
66
+
67
+ const objectNames: Record<string, string> = {
68
+ [temporaryImageName]: 'image',
69
+ [temporaryUrlName]: 'url',
70
+ }
71
+
72
+ const defaultObjectTitles: Record<string, string> = {
73
+ image: 'Image',
74
+ url: 'URL',
75
+ }
76
+
58
77
  /**
59
78
  * @public
60
79
  */
@@ -68,21 +87,30 @@ export function compileSchemaDefinition<
68
87
  defineType({
69
88
  type: 'object',
70
89
  // Very naive way to work around `SanitySchema.compile` adding default
71
- // fields to objects with the name `image`
72
- name: blockObject.name === 'image' ? 'tmp-image' : blockObject.name,
90
+ // fields to objects with certain names.
91
+ name: temporaryObjectNames[blockObject.name] ?? blockObject.name,
73
92
  title:
74
- blockObject.name === 'image' && blockObject.title === undefined
75
- ? 'Image'
93
+ blockObject.title === undefined
94
+ ? // This avoids the default title which is a title case of the object name
95
+ defaultObjectTitles[blockObject.name]
76
96
  : blockObject.title,
77
97
  fields: [],
78
98
  }),
79
99
  ) ?? []
100
+
80
101
  const inlineObjects =
81
102
  definition?.inlineObjects?.map((inlineObject) =>
82
103
  defineType({
83
104
  type: 'object',
84
- name: inlineObject.name,
85
- title: inlineObject.title,
105
+ // Very naive way to work around `SanitySchema.compile` adding default
106
+ // fields to objects with certain names.
107
+ name: temporaryObjectNames[inlineObject.name] ?? inlineObject.name,
108
+
109
+ title:
110
+ inlineObject.title === undefined
111
+ ? // This avoids the default title which is a title case of the object name
112
+ defaultObjectTitles[inlineObject.name]
113
+ : inlineObject.title,
86
114
  fields: [],
87
115
  }),
88
116
  ) ?? []
@@ -132,16 +160,24 @@ export function compileSchemaDefinition<
132
160
  return {
133
161
  ...pteSchema,
134
162
  blockObjects: pteSchema.blockObjects.map((blockObject) =>
135
- blockObject.name === 'tmp-image'
163
+ objectNames[blockObject.name] !== undefined
136
164
  ? ({
137
165
  ...blockObject,
138
- name: 'image',
166
+ name: objectNames[blockObject.name],
139
167
  type: {
140
168
  ...blockObject.type,
141
- name: 'image',
169
+ name: objectNames[blockObject.name],
142
170
  },
143
171
  } as ObjectSchemaType)
144
172
  : blockObject,
145
173
  ),
174
+ inlineObjects: pteSchema.inlineObjects.map((inlineObject) =>
175
+ objectNames[inlineObject.name] !== undefined
176
+ ? ({
177
+ ...inlineObject,
178
+ name: objectNames[inlineObject.name],
179
+ } as ObjectSchemaType)
180
+ : inlineObject,
181
+ ),
146
182
  } satisfies EditorSchema
147
183
  }