@plone/volto-slate 18.7.0 → 18.8.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,22 @@
8
8
 
9
9
  <!-- towncrier release notes start -->
10
10
 
11
+ ## 18.8.0 (2025-12-02)
12
+
13
+ ### Feature
14
+
15
+ - cross language support and umlaut fix for slash menu @Tishasoumya-02 [#7657](https://github.com/plone/volto/issues/7657)
16
+
17
+ ### Bugfix
18
+
19
+ - Ensure Delete at end of a text block merges the next text block and removes it; if the next block is non-text (e.g., Description), do nothing. @aryan7081 [#7263](https://github.com/plone/volto/issues/7263)
20
+
21
+ ## 18.7.1 (2025-10-10)
22
+
23
+ ### Bugfix
24
+
25
+ - Fix Backspace at start of a text block: merge current block into previous inline (no extra newline), delete the current block, and place the caret before the first character of the merged content. Also handle Enter immediately after such inline merge by splitting back into two blocks. @aryan7081 [#7373](https://github.com/plone/volto/issues/7373)
26
+
11
27
  ## 18.7.0 (2025-10-07)
12
28
 
13
29
  ### Feature
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plone/volto-slate",
3
- "version": "18.7.0",
3
+ "version": "18.8.0",
4
4
  "description": "Slate.js integration with Volto",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -120,7 +120,7 @@ const PersistentSlashMenu = ({ editor }) => {
120
120
  const slashCommand = data.plaintext
121
121
  ?.toLowerCase()
122
122
  .trim()
123
- .match(/^\/([a-z]*)$/);
123
+ .match(/^\/([\p{L}]*)$/u);
124
124
 
125
125
  const availableBlocks = React.useMemo(
126
126
  () =>
@@ -141,10 +141,12 @@ const PersistentSlashMenu = ({ editor }) => {
141
141
  .filter((block) => {
142
142
  // typed text is a substring of the title or id
143
143
  const title = translateBlockTitle(block, intl).toLowerCase();
144
+ const originalTitle = block.title.toLowerCase();
144
145
  return (
145
146
  block.id !== 'slate' &&
146
147
  slashCommand &&
147
- title.indexOf(slashCommand[1]) !== -1
148
+ (title.includes(slashCommand[1]) ||
149
+ originalTitle.includes(slashCommand[1]))
148
150
  );
149
151
  })
150
152
  .sort((a, b) => {
@@ -19,6 +19,7 @@ import {
19
19
  slashMenu,
20
20
  cancelEsc,
21
21
  } from './keyboard';
22
+ import { splitAtSeam } from './keyboard/splitAtSeam';
22
23
  import { withDeleteSelectionOnEnter } from '@plone/volto-slate/editor/extensions';
23
24
  import {
24
25
  breakList,
@@ -66,6 +67,7 @@ export default function applyConfig(config) {
66
67
  Enter: [
67
68
  slashMenu,
68
69
  unwrapEmptyString,
70
+ splitAtSeam,
69
71
  softBreak, // Handles shift+Enter as a newline (<br/>)
70
72
  ],
71
73
  ArrowUp: [
@@ -5,8 +5,8 @@ import { Editor } from 'slate';
5
5
  import {
6
6
  getPreviousVoltoBlock,
7
7
  getNextVoltoBlock,
8
- mergeSlateWithBlockBackward,
9
8
  mergeSlateWithBlockForward,
9
+ mergeSlateWithBlockBackward,
10
10
  } from '@plone/volto-slate/utils/volto-blocks';
11
11
  import {
12
12
  isCursorAtBlockStart,
@@ -48,8 +48,7 @@ export function joinWithPreviousBlock({ editor, event }, intl) {
48
48
  const [otherBlock = {}, otherBlockId] = prev;
49
49
 
50
50
  // Don't join with required blocks
51
- if (data?.required || otherBlock?.required || otherBlock['@type'] !== 'slate')
52
- return;
51
+ if (data?.required || otherBlock?.required) return;
53
52
 
54
53
  event.stopPropagation();
55
54
  event.preventDefault();
@@ -76,21 +75,22 @@ export function joinWithPreviousBlock({ editor, event }, intl) {
76
75
 
77
76
  // Else the editor contains characters, so we merge the current block's
78
77
  // `editor` with the block before, `otherBlock`.
79
- const cursor = mergeSlateWithBlockBackward(editor, otherBlock);
78
+ const cursor = mergeSlateWithBlockBackward(editor, otherBlock, event);
80
79
 
81
- const combined = JSON.parse(JSON.stringify(editor.children));
80
+ // Get the merged content from the editor
81
+ const merged = JSON.parse(JSON.stringify(editor.children));
82
82
 
83
83
  // // TODO: don't remove undo history, etc Should probably save both undo
84
84
  // // histories, so that the blocks are split, the undos can be restored??
85
85
 
86
86
  // const cursor = getBlockEndAsRange(otherBlock);
87
+
87
88
  const formData = changeBlock(properties, otherBlockId, {
88
89
  '@type': 'slate', // TODO: use a constant specified in src/constants.js instead of 'slate'
89
- value: combined,
90
- plaintext: serializeNodesToText(combined || []),
90
+ value: merged,
91
+ plaintext: serializeNodesToText(merged || []),
91
92
  });
92
93
  const newFormData = deleteBlock(formData, block, intl);
93
-
94
94
  ReactDOM.unstable_batchedUpdates(() => {
95
95
  saveSlateBlockSelection(otherBlockId, cursor);
96
96
  onChangeField(blocksFieldname, newFormData[blocksFieldname]);
@@ -122,6 +122,10 @@ export function joinWithNextBlock({ editor, event }, intl) {
122
122
  const { properties, onChangeField } = editor.getBlockProps();
123
123
  const [otherBlock = {}, otherBlockId] = getNextVoltoBlock(index, properties);
124
124
 
125
+ if (!otherBlockId) {
126
+ return false;
127
+ }
128
+
125
129
  // Don't join with required blocks
126
130
  if (data?.required || otherBlock?.required || otherBlock['@type'] !== 'slate')
127
131
  return;
@@ -129,30 +133,51 @@ export function joinWithNextBlock({ editor, event }, intl) {
129
133
  event.stopPropagation();
130
134
  event.preventDefault();
131
135
 
132
- mergeSlateWithBlockForward(editor, otherBlock);
136
+ const blocksFieldname = getBlocksFieldname(properties);
137
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
133
138
 
134
- // const cursor = JSON.parse(JSON.stringify(editor.selection));
135
- const combined = JSON.parse(JSON.stringify(editor.children));
139
+ // If next block is not a slate text block, do nothing
140
+ if (otherBlock['@type'] !== 'slate') {
141
+ return;
142
+ }
136
143
 
137
- // TODO: don't remove undo history, etc Should probably save both undo
138
- // histories, so that the blocks are split, the undos can be restored??
144
+ const nextValue = otherBlock?.value;
145
+ const nextPlaintext =
146
+ otherBlock?.plaintext ?? serializeNodesToText(nextValue || []);
147
+ // Treat the next block as empty if both its structured value and plaintext representation
148
+ // indicate no content. In that case we can delete it instead of attempting a merge.
149
+ const isEmptySlateBlock =
150
+ !Array.isArray(nextValue) ||
151
+ nextValue.length === 0 ||
152
+ !nextPlaintext ||
153
+ nextPlaintext.trim().length === 0;
154
+
155
+ if (isEmptySlateBlock) {
156
+ const newFormData = deleteBlock(properties, otherBlockId, intl);
157
+ ReactDOM.unstable_batchedUpdates(() => {
158
+ onChangeField(blocksFieldname, newFormData[blocksFieldname]);
159
+ onChangeField(blocksLayoutFieldname, newFormData[blocksLayoutFieldname]);
160
+ onSelectBlock(block);
161
+ });
162
+ return true;
163
+ }
164
+ // Merge next text block into current one and delete the next block
165
+ mergeSlateWithBlockForward(editor, otherBlock);
139
166
 
140
- const blocksFieldname = getBlocksFieldname(properties);
141
- const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
167
+ const combined = JSON.parse(JSON.stringify(editor.children));
142
168
 
143
- const formData = changeBlock(properties, otherBlockId, {
144
- // TODO: use a constant specified in src/constants.js instead of 'slate'
169
+ const formData = changeBlock(properties, block, {
145
170
  '@type': 'slate',
146
171
  value: combined,
147
172
  plaintext: serializeNodesToText(combined || []),
148
173
  });
149
- const newFormData = deleteBlock(formData, block, intl);
174
+
175
+ const newFormData = deleteBlock(formData, otherBlockId, intl);
150
176
 
151
177
  ReactDOM.unstable_batchedUpdates(() => {
152
- // saveSlateBlockSelection(otherBlockId, cursor);
153
178
  onChangeField(blocksFieldname, newFormData[blocksFieldname]);
154
179
  onChangeField(blocksLayoutFieldname, newFormData[blocksLayoutFieldname]);
155
- onSelectBlock(otherBlockId);
180
+ onSelectBlock(block);
156
181
  });
157
182
  return true;
158
183
  }
@@ -0,0 +1,44 @@
1
+ import ReactDOM from 'react-dom';
2
+ import { Editor, Range } from 'slate';
3
+ import { splitEditorInTwoFragments } from '@plone/volto-slate/utils/ops';
4
+ import { setEditorContent } from '@plone/volto-slate/utils/editor';
5
+ import { createAndSelectNewBlockAfter } from '@plone/volto-slate/utils/volto-blocks';
6
+
7
+ /**
8
+ * Handle Enter after an inline Backspace-merge seam.
9
+ *
10
+ * Detects when the caret is at offset 0 of a leaf that is not the first
11
+ * leaf of a top-level node (e.g., after merging two blocks of same type),
12
+ * and splits the current Slate block into two Volto Slate blocks.
13
+ */
14
+ export function splitAtSeam({ editor, event }, intl) {
15
+ const { selection } = editor;
16
+ if (!selection || !Range.isCollapsed(selection)) return;
17
+
18
+ const point = selection.anchor;
19
+ if (point.offset !== 0) return;
20
+
21
+ // We are interested in positions like [paragraphIndex, leafIndex]
22
+ // where leafIndex > 0 (caret at the beginning of a subsequent leaf)
23
+ if (!point.path || point.path.length < 2) return;
24
+
25
+ const leafIndex = point.path[1];
26
+ if (leafIndex <= 0) return; // first leaf, let default handlers run
27
+
28
+ // Ensure parent is a top-level block (parent path length 1)
29
+ const parentEntry = Editor.parent(editor, point);
30
+ if (!parentEntry) return;
31
+ const parentPath = parentEntry[1];
32
+ if (!parentPath || parentPath.length !== 1) return;
33
+
34
+ event.preventDefault();
35
+ event.stopPropagation();
36
+
37
+ ReactDOM.unstable_batchedUpdates(() => {
38
+ const [top, bottom] = splitEditorInTwoFragments(editor);
39
+ createAndSelectNewBlockAfter(editor, bottom, intl);
40
+ setEditorContent(editor, top);
41
+ });
42
+
43
+ return true;
44
+ }
@@ -82,10 +82,8 @@ export function isCursorAtBlockStart(editor) {
82
82
 
83
83
  if (editor.selection && Range.isCollapsed(editor.selection)) {
84
84
  const { anchor } = editor.selection;
85
- return anchor.offset > 0
86
- ? false
87
- : anchor.path.reduce((acc, x) => acc + x, 0) === 0;
88
- // anchor.path.length === 2 &&
85
+ // Check if cursor is at offset 0 of any leaf node (not just the first one)
86
+ return anchor.offset === 0;
89
87
  }
90
88
  return false;
91
89
  }
@@ -8,7 +8,7 @@ import {
8
8
  getBlocksFieldname,
9
9
  getBlocksLayoutFieldname,
10
10
  } from '@plone/volto/helpers/Blocks/Blocks';
11
- import { Transforms, Editor, Node, Text, Path } from 'slate';
11
+ import { Transforms, Editor, Node } from 'slate';
12
12
  import { serializeNodesToText } from '@plone/volto-slate/editor/render';
13
13
  import omit from 'lodash/omit';
14
14
  import config from '@plone/volto/registry';
@@ -21,74 +21,64 @@ function fromEntries(pairs) {
21
21
  return res;
22
22
  }
23
23
 
24
- // TODO: should be made generic, no need for "prevBlock.value"
25
24
  export function mergeSlateWithBlockBackward(editor, prevBlock, event) {
26
- // To work around current architecture limitations, read the value from
27
- // previous block. Replace it in the current editor (over which we have
28
- // control), join with current block value, then use this result for previous
29
- // block, delete current block
30
-
31
- const prev = prevBlock.value;
32
-
33
- // collapse the selection to its start point
34
- Transforms.collapse(editor, { edge: 'start' });
35
-
36
- let rangeRef;
37
- let end;
38
-
39
- Editor.withoutNormalizing(editor, () => {
40
- // insert block #0 contents in block #1 contents, at the beginning
41
- Transforms.insertNodes(editor, prev, {
42
- at: Editor.start(editor, []),
43
- });
44
-
45
- // the contents that should be moved into the `ul`, as the last `li`
46
- rangeRef = Editor.rangeRef(editor, {
47
- anchor: Editor.start(editor, [1]),
48
- focus: Editor.end(editor, [1]),
49
- });
50
-
51
- const source = rangeRef.current;
52
-
53
- end = Editor.end(editor, [0]);
54
-
55
- let endPoint;
56
-
57
- Transforms.insertNodes(editor, { text: '' }, { at: end });
58
-
59
- end = Editor.end(editor, [0]);
60
-
61
- Transforms.splitNodes(editor, {
62
- at: end,
63
- always: true,
64
- height: 1,
65
- mode: 'highest',
66
- match: (n) => n.type === 'li' || Text.isText(n),
67
- });
68
-
69
- endPoint = Editor.end(editor, [0]);
70
-
71
- Transforms.moveNodes(editor, {
72
- at: source,
73
- to: endPoint.path,
74
- mode: 'all',
75
- match: (n, p) => p.length === 2,
76
- });
77
- });
25
+ // Merge the current block content into the previous block
26
+ // The current block content should be appended to the previous block
27
+ // and the cursor should be positioned at the start of what was the current block content
28
+
29
+ const prevValue = [...prevBlock.value];
30
+ const currentValue = [...editor.children];
31
+
32
+ const lastNode = prevValue[prevValue.length - 1];
33
+ const firstNode = currentValue[0];
34
+ let merged;
35
+ let cursor;
36
+
37
+ if (lastNode && firstNode && lastNode.type === firstNode.type) {
38
+ // If the last node of previous block and first node of current block have the same type,
39
+ // merge their children together
40
+ const mergedFirstNode = {
41
+ ...lastNode,
42
+ children: [...lastNode.children, ...firstNode.children],
43
+ };
78
44
 
79
- const [n] = Editor.node(editor, [1]);
45
+ merged = [
46
+ ...prevValue.slice(0, -1),
47
+ mergedFirstNode,
48
+ ...currentValue.slice(1),
49
+ ];
80
50
 
81
- if (Editor.isEmpty(editor, n)) {
82
- Transforms.removeNodes(editor, { at: [1] });
51
+ cursor = {
52
+ anchor: {
53
+ path: [prevValue.length - 1, lastNode.children.length],
54
+ offset: 0,
55
+ },
56
+ focus: {
57
+ path: [prevValue.length - 1, lastNode.children.length],
58
+ offset: 0,
59
+ },
60
+ };
61
+ } else {
62
+ // Otherwise, just append the current block content to the previous block
63
+ merged = [...prevValue, ...currentValue];
64
+ // Position cursor at the start of the merged content (where current block was inserted)
65
+ // The current block content starts at index prevValue.length in the merged array
66
+ cursor = {
67
+ anchor: {
68
+ path: [prevValue.length, 0],
69
+ offset: 0,
70
+ },
71
+ focus: {
72
+ path: [prevValue.length, 0],
73
+ offset: 0,
74
+ },
75
+ };
83
76
  }
84
77
 
85
- rangeRef.unref();
78
+ // Update the editor with the merged content
79
+ editor.children = merged;
86
80
 
87
- const [, lastPath] = Editor.last(editor, [0]);
88
-
89
- end = Editor.start(editor, Path.parent(lastPath));
90
-
91
- return end;
81
+ return cursor;
92
82
  }
93
83
 
94
84
  export function mergeSlateWithBlockForward(editor, nextBlock, event) {
@@ -97,7 +87,11 @@ export function mergeSlateWithBlockForward(editor, nextBlock, event) {
97
87
  // with current block value, then use this result for next block, delete
98
88
  // current block
99
89
 
100
- const next = nextBlock.value;
90
+ const next = nextBlock?.value;
91
+ // Safeguard: if next block has no value or an empty value, there is nothing to merge
92
+ if (!Array.isArray(next) || next.length === 0) {
93
+ return;
94
+ }
101
95
 
102
96
  // collapse the selection to its start point
103
97
  Transforms.collapse(editor, { edge: 'end' });