Skip to content

Commit dfb8f40

Browse files
committed
fix: Backspace behaving erratically near a styled or link text (#8355)
1 parent e0e0995 commit dfb8f40

6 files changed

Lines changed: 243 additions & 14 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix Backspace behaving erratically near a styled or link text and fix cursor position after merging two same-type slate blocks via Backspace. @avoinea

packages/volto-slate/src/utils/selection.js

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
11
import castArray from 'lodash/castArray';
22
import cloneDeep from 'lodash/cloneDeep';
3-
import { Editor, Transforms, Range, Node } from 'slate';
3+
import { Editor, Transforms, Range, Node, Path } from 'slate';
44
import { ReactEditor } from 'slate-react';
55
import { isCursorInList } from '@plone/volto-slate/utils/lists';
66
import { makeEditor } from '@plone/volto-slate/utils/editor';
77
import { LI } from '@plone/volto-slate/constants';
88
import config from '@plone/volto/registry';
99

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+
1032
/**
1133
* Get the nodes with a type included in `types` in the selection (from root to leaf).
1234
*
@@ -82,8 +104,9 @@ export function isCursorAtBlockStart(editor) {
82104

83105
if (editor.selection && Range.isCollapsed(editor.selection)) {
84106
const { anchor } = editor.selection;
85-
// Check if cursor is at offset 0 of any leaf node (not just the first one)
86-
return anchor.offset === 0;
107+
if (anchor.offset !== 0) return false;
108+
if (!editor.children?.length) return false;
109+
return isAtFirstLeaf(editor, anchor.path);
87110
}
88111
return false;
89112
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

packages/volto-slate/src/utils/volto-blocks.js

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
getBlocksFieldname,
99
getBlocksLayoutFieldname,
1010
} from '@plone/volto/helpers/Blocks/Blocks';
11-
import { Transforms, Editor, Node } from 'slate';
11+
import { Transforms, Editor, Node, Text } from 'slate';
1212
import { serializeNodesToText } from '@plone/volto-slate/editor/render';
1313
import omit from 'lodash/omit';
1414
import config from '@plone/volto/registry';
@@ -48,16 +48,30 @@ export function mergeSlateWithBlockBackward(editor, prevBlock, event) {
4848
...currentValue.slice(1),
4949
];
5050

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-
};
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+
}
6175
} else {
6276
// Otherwise, just append the current block content to the previous block
6377
merged = [...prevValue, ...currentValue];

packages/volto/cypress/tests/core/blocks/blocks-slate-backspace.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,77 @@ describe('Slate Backspace Behavior', () => {
4949
.eq(1)
5050
.should('contain', 'World');
5151
});
52+
53+
// Regression test for #8347: Backspace right after a styled (bold) inline
54+
// element must NOT be treated as "cursor at block start".
55+
it('Backspace right after a bold inline element does not merge blocks', () => {
56+
cy.getSlateEditorAndType('First block');
57+
58+
cy.getSlateEditorAndType('{enter}');
59+
cy.getSlateEditorAndType('JavaScript programming');
60+
61+
// Turn "JavaScript" into a bold inline element.
62+
cy.setSlateSelection('JavaScript');
63+
cy.clickSlateButton('Bold');
64+
65+
// Place the caret at the start of the text leaf that follows the bold
66+
// element (offset 0 of a non-first leaf).
67+
cy.getSlate().setCursorBefore('programming').type('{backspace}');
68+
69+
cy.get('.content-area .slate-editor [contenteditable=true]').should(
70+
'have.length',
71+
2,
72+
);
73+
cy.get('.content-area .slate-editor [contenteditable=true]')
74+
.eq(0)
75+
.should('contain', 'First block');
76+
cy.get('.content-area .slate-editor [contenteditable=true]')
77+
.eq(1)
78+
.should('contain', 'JavaScript');
79+
cy.get('.content-area .slate-editor [contenteditable=true] strong').should(
80+
'have.text',
81+
'JavaScript',
82+
);
83+
});
84+
85+
// Regression test for #8347: a list item containing an inline link followed
86+
// by a text leaf. Backspace at the start of that trailing leaf used to crash
87+
// slate-react with "Cannot get the leaf node at path [1,0] ...".
88+
it('Backspace right after a link inside a list item does not corrupt the block', () => {
89+
cy.getSlateEditorAndType('First block');
90+
91+
cy.getSlateEditorAndType('{enter}');
92+
cy.getSlateEditorAndType('Built with JavaScript programming');
93+
94+
// Convert the line into a bulleted list first (while the text is still
95+
// plain, so setSlateSelection can find it as a single text node).
96+
cy.setSlateSelection('Built with JavaScript programming');
97+
cy.clickSlateButton('Bulleted list');
98+
99+
// Now turn "JavaScript" into a link inside the list item.
100+
cy.setSlateSelection('JavaScript');
101+
cy.clickSlateButton('Add link');
102+
cy.get('.slate-toolbar .link-form-container input').type(
103+
'https://demo.plone.org/{enter}',
104+
);
105+
106+
// Place the caret at the start of the text leaf that follows the link
107+
// inside the list item, then backspace.
108+
cy.getSlate().setCursorBefore('programming').type('{backspace}');
109+
110+
cy.get('.content-area .slate-editor [contenteditable=true]').should(
111+
'have.length',
112+
2,
113+
);
114+
cy.get('.content-area .slate-editor [contenteditable=true]')
115+
.eq(0)
116+
.should('contain', 'First block');
117+
cy.get('.content-area .slate-editor [contenteditable=true]')
118+
.eq(1)
119+
.should('contain', 'JavaScript');
120+
cy.get('.content-area .slate-editor [contenteditable=true] a')
121+
.should('have.text', 'JavaScript')
122+
.and('have.attr', 'href')
123+
.and('include', 'https://demo.plone.org');
124+
});
52125
});

packages/volto/news/8347.internal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added Cypress regression tests for slate Backspace behavior near styled/link inline elements. @avoinea

0 commit comments

Comments
 (0)