@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 +16 -0
- package/package.json +1 -1
- package/src/blocks/Text/SlashMenu.jsx +4 -2
- package/src/blocks/Text/index.jsx +2 -0
- package/src/blocks/Text/keyboard/joinBlocks.js +45 -20
- package/src/blocks/Text/keyboard/splitAtSeam.js +44 -0
- package/src/utils/selection.js +2 -4
- package/src/utils/volto-blocks.js +58 -64
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
|
@@ -120,7 +120,7 @@ const PersistentSlashMenu = ({ editor }) => {
|
|
|
120
120
|
const slashCommand = data.plaintext
|
|
121
121
|
?.toLowerCase()
|
|
122
122
|
.trim()
|
|
123
|
-
.match(/^\/([
|
|
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.
|
|
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
|
|
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
|
-
|
|
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:
|
|
90
|
-
plaintext: serializeNodesToText(
|
|
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
|
-
|
|
136
|
+
const blocksFieldname = getBlocksFieldname(properties);
|
|
137
|
+
const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
|
|
133
138
|
|
|
134
|
-
//
|
|
135
|
-
|
|
139
|
+
// If next block is not a slate text block, do nothing
|
|
140
|
+
if (otherBlock['@type'] !== 'slate') {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
136
143
|
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
141
|
-
const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
|
|
167
|
+
const combined = JSON.parse(JSON.stringify(editor.children));
|
|
142
168
|
|
|
143
|
-
const formData = changeBlock(properties,
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/utils/selection.js
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
let
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
+
merged = [
|
|
46
|
+
...prevValue.slice(0, -1),
|
|
47
|
+
mergedFirstNode,
|
|
48
|
+
...currentValue.slice(1),
|
|
49
|
+
];
|
|
80
50
|
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
78
|
+
// Update the editor with the merged content
|
|
79
|
+
editor.children = merged;
|
|
86
80
|
|
|
87
|
-
|
|
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
|
|
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' });
|