@plone/volto-slate 19.0.3 → 19.0.4
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 +6 -0
- package/package.json +1 -1
- package/src/utils/selection.js +26 -3
- package/src/utils/selection.test.js +117 -0
- package/src/utils/volto-blocks.js +25 -11
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
<!-- towncrier release notes start -->
|
|
10
10
|
|
|
11
|
+
## 19.0.4 (2026-07-16)
|
|
12
|
+
|
|
13
|
+
### Bugfix
|
|
14
|
+
|
|
15
|
+
- Fix Backspace behaving erratically near a styled or link text and fix cursor position after merging two same-type slate blocks via Backspace. @avoinea [#8347](https://github.com/plone/volto/issues/8347)
|
|
16
|
+
|
|
11
17
|
## 19.0.3 (2026-07-01)
|
|
12
18
|
|
|
13
19
|
### Bugfix
|
package/package.json
CHANGED
package/src/utils/selection.js
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
import castArray from 'lodash/castArray';
|
|
2
2
|
import cloneDeep from 'lodash/cloneDeep';
|
|
3
|
-
import { Editor, Transforms, Range, Node } from 'slate';
|
|
3
|
+
import { Editor, Transforms, Range, Node, Path } from 'slate';
|
|
4
4
|
import { ReactEditor } from 'slate-react';
|
|
5
5
|
import { isCursorInList } from '@plone/volto-slate/utils/lists';
|
|
6
6
|
import { makeEditor } from '@plone/volto-slate/utils/editor';
|
|
7
7
|
import { LI } from '@plone/volto-slate/constants';
|
|
8
8
|
import config from '@plone/volto/registry';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* firstLeafPath.
|
|
12
|
+
*
|
|
13
|
+
* @param {} editor
|
|
14
|
+
*/
|
|
15
|
+
function firstLeafPath(editor) {
|
|
16
|
+
if (!editor.children?.length) return null;
|
|
17
|
+
return Node.first(editor, [])[1];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* isAtFirstLeaf.
|
|
22
|
+
*
|
|
23
|
+
* @param {} editor
|
|
24
|
+
* @param {} path
|
|
25
|
+
*/
|
|
26
|
+
function isAtFirstLeaf(editor, path) {
|
|
27
|
+
const first = firstLeafPath(editor);
|
|
28
|
+
if (!first) return false;
|
|
29
|
+
return Path.equals(path, first);
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
/**
|
|
11
33
|
* Get the nodes with a type included in `types` in the selection (from root to leaf).
|
|
12
34
|
*
|
|
@@ -82,8 +104,9 @@ export function isCursorAtBlockStart(editor) {
|
|
|
82
104
|
|
|
83
105
|
if (editor.selection && Range.isCollapsed(editor.selection)) {
|
|
84
106
|
const { anchor } = editor.selection;
|
|
85
|
-
|
|
86
|
-
|
|
107
|
+
if (anchor.offset !== 0) return false;
|
|
108
|
+
if (!editor.children?.length) return false;
|
|
109
|
+
return isAtFirstLeaf(editor, anchor.path);
|
|
87
110
|
}
|
|
88
111
|
return false;
|
|
89
112
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isCursorAtBlockStart } from './selection';
|
|
3
|
+
|
|
4
|
+
// `isCursorAtBlockStart` only inspects `editor.selection` and `editor.children`,
|
|
5
|
+
// so plain editor-shaped objects are enough (no full Volto config registry).
|
|
6
|
+
const makeEditor = (children, selection) => ({ children, selection });
|
|
7
|
+
const at = (path, offset) => ({ path, offset });
|
|
8
|
+
const collapsed = (path, offset) => ({
|
|
9
|
+
anchor: at(path, offset),
|
|
10
|
+
focus: at(path, offset),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('isCursorAtBlockStart', () => {
|
|
14
|
+
it('returns true when the caret is at the first leaf of the block', () => {
|
|
15
|
+
const editor = makeEditor(
|
|
16
|
+
[{ type: 'p', children: [{ text: 'Hello' }] }],
|
|
17
|
+
collapsed([0, 0], 0),
|
|
18
|
+
);
|
|
19
|
+
expect(isCursorAtBlockStart(editor)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns true for the first leaf of a nested list item (path [0,0,0])', () => {
|
|
23
|
+
const editor = makeEditor(
|
|
24
|
+
[
|
|
25
|
+
{
|
|
26
|
+
type: 'ul',
|
|
27
|
+
children: [
|
|
28
|
+
{
|
|
29
|
+
type: 'li',
|
|
30
|
+
children: [{ text: 'first item' }],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
collapsed([0, 0, 0], 0),
|
|
36
|
+
);
|
|
37
|
+
expect(isCursorAtBlockStart(editor)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns false at offset 0 of a non-first leaf inside a list item (#8347)', () => {
|
|
41
|
+
// A list item: {text:""} + link + {text:""} — the empty text leaf after
|
|
42
|
+
// the link is at path [0,0,2]. Backspace there must NOT count as block start.
|
|
43
|
+
const editor = makeEditor(
|
|
44
|
+
[
|
|
45
|
+
{
|
|
46
|
+
type: 'ul',
|
|
47
|
+
children: [
|
|
48
|
+
{
|
|
49
|
+
type: 'li',
|
|
50
|
+
children: [
|
|
51
|
+
{ text: '' },
|
|
52
|
+
{
|
|
53
|
+
type: 'link',
|
|
54
|
+
data: { url: 'https://demo.plone.org/' },
|
|
55
|
+
children: [{ text: 'Plone 6 (this site)' }],
|
|
56
|
+
},
|
|
57
|
+
{ text: '' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
collapsed([0, 0, 2], 0),
|
|
64
|
+
);
|
|
65
|
+
expect(isCursorAtBlockStart(editor)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns false at offset 0 of a non-first leaf of a plain paragraph', () => {
|
|
69
|
+
const editor = makeEditor(
|
|
70
|
+
[
|
|
71
|
+
{
|
|
72
|
+
type: 'p',
|
|
73
|
+
children: [
|
|
74
|
+
{ text: 'before' },
|
|
75
|
+
{
|
|
76
|
+
type: 'link',
|
|
77
|
+
data: { url: 'https://example.com' },
|
|
78
|
+
children: [{ text: 'link' }],
|
|
79
|
+
},
|
|
80
|
+
{ text: 'after' },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
collapsed([0, 2], 0),
|
|
85
|
+
);
|
|
86
|
+
expect(isCursorAtBlockStart(editor)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns false when the offset is greater than 0', () => {
|
|
90
|
+
const editor = makeEditor(
|
|
91
|
+
[{ type: 'p', children: [{ text: 'Hello' }] }],
|
|
92
|
+
collapsed([0, 0], 2),
|
|
93
|
+
);
|
|
94
|
+
expect(isCursorAtBlockStart(editor)).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns false when there is no selection', () => {
|
|
98
|
+
const editor = makeEditor(
|
|
99
|
+
[{ type: 'p', children: [{ text: 'Hi' }] }],
|
|
100
|
+
null,
|
|
101
|
+
);
|
|
102
|
+
expect(isCursorAtBlockStart(editor)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns false when the selection is expanded', () => {
|
|
106
|
+
const editor = makeEditor([{ type: 'p', children: [{ text: 'Hello' }] }], {
|
|
107
|
+
anchor: at([0, 0], 0),
|
|
108
|
+
focus: at([0, 0], 3),
|
|
109
|
+
});
|
|
110
|
+
expect(isCursorAtBlockStart(editor)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns false when the editor has no children', () => {
|
|
114
|
+
const editor = makeEditor([], collapsed([0, 0], 0));
|
|
115
|
+
expect(isCursorAtBlockStart(editor)).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
getBlocksFieldname,
|
|
9
9
|
getBlocksLayoutFieldname,
|
|
10
10
|
} from '@plone/volto/helpers/Blocks/Blocks';
|
|
11
|
-
import { Transforms, Editor, Node } from 'slate';
|
|
11
|
+
import { Transforms, Editor, Node, Text } 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';
|
|
@@ -48,16 +48,30 @@ export function mergeSlateWithBlockBackward(editor, prevBlock, event) {
|
|
|
48
48
|
...currentValue.slice(1),
|
|
49
49
|
];
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
51
|
+
const lastChild = lastNode.children[lastNode.children.length - 1];
|
|
52
|
+
if (Text.isText(lastChild)) {
|
|
53
|
+
cursor = {
|
|
54
|
+
anchor: {
|
|
55
|
+
path: [prevValue.length - 1, lastNode.children.length - 1],
|
|
56
|
+
offset: lastChild.text.length,
|
|
57
|
+
},
|
|
58
|
+
focus: {
|
|
59
|
+
path: [prevValue.length - 1, lastNode.children.length - 1],
|
|
60
|
+
offset: lastChild.text.length,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
} else {
|
|
64
|
+
cursor = {
|
|
65
|
+
anchor: {
|
|
66
|
+
path: [prevValue.length - 1, lastNode.children.length],
|
|
67
|
+
offset: 0,
|
|
68
|
+
},
|
|
69
|
+
focus: {
|
|
70
|
+
path: [prevValue.length - 1, lastNode.children.length],
|
|
71
|
+
offset: 0,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
61
75
|
} else {
|
|
62
76
|
// Otherwise, just append the current block content to the previous block
|
|
63
77
|
merged = [...prevValue, ...currentValue];
|