@eeacms/volto-eea-website-theme 4.3.9 → 4.4.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
@@ -4,11 +4,16 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
- ### [4.3.9](https://github.com/eea/volto-eea-website-theme/compare/4.3.8...4.3.9) - 23 June 2026
7
+ ### [4.4.0](https://github.com/eea/volto-eea-website-theme/compare/4.3.9...4.4.0) - 24 June 2026
8
8
 
9
9
  #### :bug: Bug Fixes
10
10
 
11
- - fix: hydrate error pages with client scripts - refs #304005 [Teodor Voicu - [`39c5ab5`](https://github.com/eea/volto-eea-website-theme/commit/39c5ab5755c65bb8640abb76f9d72b054a256226)]
11
+ - fix: Backspace behaving erratically near a styled or link text - refs #304369 [Alin Voinea - [`7f9fc05`](https://github.com/eea/volto-eea-website-theme/commit/7f9fc05814a0008a115596c5ef1875ef65269fac)]
12
+
13
+ #### :hammer_and_wrench: Others
14
+
15
+ - Release 4.4.0 [Alin Voinea - [`3ae92a0`](https://github.com/eea/volto-eea-website-theme/commit/3ae92a071a6929611c5cac1b8f152be863a84eb4)]
16
+ ### [4.3.9](https://github.com/eea/volto-eea-website-theme/compare/4.3.8...4.3.9) - 23 June 2026
12
17
 
13
18
  ### [4.3.8](https://github.com/eea/volto-eea-website-theme/compare/4.3.7...4.3.8) - 9 June 2026
14
19
 
@@ -126,8 +131,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
126
131
 
127
132
  #### :house: Internal changes
128
133
 
129
- - chore: [JENKINSFILE] add package version in sonarqube [valentinab25 - [`b15b302`](https://github.com/eea/volto-eea-website-theme/commit/b15b302ff88ec6afa6901ee7e1726cc4e04a8739)]
130
- - chore: [JENKINSFILE] use sonarqube branches [EEA Jenkins - [`36f2e1e`](https://github.com/eea/volto-eea-website-theme/commit/36f2e1e176471b3753d77ab8aaf0cba577b43b36)]
131
134
 
132
135
  ### [3.19.0](https://github.com/eea/volto-eea-website-theme/compare/3.18.1...3.19.0) - 11 February 2026
133
136
 
@@ -190,7 +193,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
190
193
  #### :hammer_and_wrench: Others
191
194
 
192
195
  - Release 3.12.0 [Alin Voinea - [`ea1e961`](https://github.com/eea/volto-eea-website-theme/commit/ea1e96144684b2a8238ae476fd8d47de82ff42b4)]
193
- - Add Sonarqube tag using bise-frontend addons list [EEA Jenkins - [`2cac56e`](https://github.com/eea/volto-eea-website-theme/commit/2cac56e22e580e042d368b69254172539ed33b65)]
194
196
  ### [3.11.0](https://github.com/eea/volto-eea-website-theme/compare/3.10.1...3.11.0) - 29 September 2025
195
197
 
196
198
  #### :bug: Bug Fixes
@@ -284,8 +286,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
284
286
 
285
287
  #### :hammer_and_wrench: Others
286
288
 
287
- - Add Sonarqube tag using fise-frontend addons list [EEA Jenkins - [`3d50874`](https://github.com/eea/volto-eea-website-theme/commit/3d50874281ebcb91a1ae3727248f656a6f26c603)]
288
- - Add Sonarqube tag using ied-frontend addons list [EEA Jenkins - [`a3f7676`](https://github.com/eea/volto-eea-website-theme/commit/a3f76767d99cb6dd93cc7fa0eafdea57e9000b2c)]
289
289
  ### [3.5.4](https://github.com/eea/volto-eea-website-theme/compare/3.5.3...3.5.4) - 30 January 2025
290
290
 
291
291
  #### :bug: Bug Fixes
@@ -386,7 +386,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
386
386
 
387
387
  #### :hammer_and_wrench: Others
388
388
 
389
- - Add Sonarqube tag using bise-frontend addons list [EEA Jenkins - [`e417f83`](https://github.com/eea/volto-eea-website-theme/commit/e417f839312045c56b67ab8134fe5b0622b3e2c3)]
390
389
  ## [3.0.0](https://github.com/eea/volto-eea-website-theme/compare/2.4.0...3.0.0) - 21 October 2024
391
390
 
392
391
  #### :nail_care: Enhancements
@@ -472,7 +471,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
472
471
  - Refs #269520 - other_organisation test [Tripon Eugen - [`7558b27`](https://github.com/eea/volto-eea-website-theme/commit/7558b27a9d1cd6d3480a32b296c7325c796006c9)]
473
472
  - Refs #269520 - other_organisation token widget edit mode [Tripon Eugen - [`b6df127`](https://github.com/eea/volto-eea-website-theme/commit/b6df127bd91b6f55dda8f468e1107037d43752ff)]
474
473
  - Refs #269520 - other_organisation token widget [Tripon Eugen - [`6fd7543`](https://github.com/eea/volto-eea-website-theme/commit/6fd7543894bd2c593a1729116befd95c82ea3c8f)]
475
- - Add Sonarqube tag using marine-frontend addons list [EEA Jenkins - [`cbc36c7`](https://github.com/eea/volto-eea-website-theme/commit/cbc36c74dfcd5f7cb1013a4c673d9c02e0023766)]
476
474
  ### [2.1.1](https://github.com/eea/volto-eea-website-theme/compare/2.1.0...2.1.1) - 28 May 2024
477
475
 
478
476
  #### :bug: Bug Fixes
@@ -557,7 +555,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
557
555
 
558
556
  #### :hammer_and_wrench: Others
559
557
 
560
- - Add Sonarqube tag using insitu-frontend addons list [EEA Jenkins - [`adc6730`](https://github.com/eea/volto-eea-website-theme/commit/adc6730e21a37afb865b842182624401de6a29f5)]
561
558
  ### [1.33.1](https://github.com/eea/volto-eea-website-theme/compare/1.33.0...1.33.1) - 4 April 2024
562
559
 
563
560
  #### :bug: Bug Fixes
@@ -691,8 +688,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
691
688
 
692
689
  - bump version [Razvan - [`721e939`](https://github.com/eea/volto-eea-website-theme/commit/721e939d12e324b459ebfa78a2e656ee7142a3d6)]
693
690
  - merge master into this branch [Razvan - [`586c8f9`](https://github.com/eea/volto-eea-website-theme/commit/586c8f910bac55a043bd8dda60e9444bd2ae1663)]
694
- - Add Sonarqube tag using freshwater-frontend addons list [EEA Jenkins - [`fd90044`](https://github.com/eea/volto-eea-website-theme/commit/fd9004442a9d1d465f7601ecdefe3e23c61e6a9c)]
695
- - Add Sonarqube tag using insitu-frontend addons list [EEA Jenkins - [`4bc3dd3`](https://github.com/eea/volto-eea-website-theme/commit/4bc3dd3ae412a66befd04b5b80fab3716c929240)]
696
691
  - test: Update jest,Jenkinsfile,lint to volto-addons-template PR30 [valentinab25 - [`c4dbd28`](https://github.com/eea/volto-eea-website-theme/commit/c4dbd289358205bc2d849aab7edb11ccf3b89cee)]
697
692
  - fix tests [Razvan - [`042330b`](https://github.com/eea/volto-eea-website-theme/commit/042330bc97d32ffe7ba769b4f2453f71cffed706)]
698
693
  - remove RemoveSchema logic [Razvan - [`08d10f8`](https://github.com/eea/volto-eea-website-theme/commit/08d10f8bf6f75478260e4e4c66d7316ba87b907a)]
@@ -787,11 +782,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
787
782
  - test: Add real image to cypress test [Alin Voinea - [`4ff591a`](https://github.com/eea/volto-eea-website-theme/commit/4ff591ae3318c9588b4e2114582c0fa6cfdf31ae)]
788
783
  - test: Add cypress tests for Image block styling position and align [Alin Voinea - [`7341ef7`](https://github.com/eea/volto-eea-website-theme/commit/7341ef7b92714fc0cc3ab0c31c39033e7b3e19e7)]
789
784
  - Revert "change(tests): commented out rss test since title block config is missing" [Alin Voinea - [`fb61191`](https://github.com/eea/volto-eea-website-theme/commit/fb611918d6ca380b89b594f283dcf9f685a4b294)]
790
- - test: [JENKINS] Use java17 for sonarqube scanner [valentinab25 - [`6a3be30`](https://github.com/eea/volto-eea-website-theme/commit/6a3be3092589411af7808a235f76de5222fd3868)]
791
- - test: [JENKINS] Run cypress in started frontend container [valentinab25 - [`c3978f2`](https://github.com/eea/volto-eea-website-theme/commit/c3978f23375ef066e9fd6f6c2e34ba6c1c058f69)]
792
- - test: [JENKINS] Add cpu limit on cypress docker [valentinab25 - [`f672779`](https://github.com/eea/volto-eea-website-theme/commit/f672779e845bec9240ccc901e9f53ec80c5a1819)]
793
- - test: [JENKINS] Increase shm-size to cypress docker [valentinab25 - [`ae5d8e3`](https://github.com/eea/volto-eea-website-theme/commit/ae5d8e3f4e04dc2808d47ce2ee886e1b23b528da)]
794
- - test: [JENKINS] Improve cypress time [valentinab25 - [`170ff0c`](https://github.com/eea/volto-eea-website-theme/commit/170ff0c8e3b30e69479bdf1117e811fea94f1027)]
795
785
  ### [1.23.0](https://github.com/eea/volto-eea-website-theme/compare/1.22.1...1.23.0) - 2 November 2023
796
786
 
797
787
  #### :rocket: New Features
@@ -804,7 +794,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
804
794
 
805
795
  #### :house: Internal changes
806
796
 
807
- - chore: [JENKINS] Refactor automated testing [valentinab25 - [`f28fce3`](https://github.com/eea/volto-eea-website-theme/commit/f28fce3d1eb815f95fb9aa40de42b10b7e8e30c5)]
808
797
  - chore: husky, lint-staged use fixed versions [valentinab25 - [`6d15088`](https://github.com/eea/volto-eea-website-theme/commit/6d150886c5aeb2ca0b569270486e60f7cc274e2c)]
809
798
  - chore:volto 16 in tests, update docs, fix stylelint overrides [valentinab25 - [`20c0323`](https://github.com/eea/volto-eea-website-theme/commit/20c032380b33c0077c869a05136f93e2fb68e5d4)]
810
799
 
@@ -990,7 +979,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
990
979
 
991
980
  #### :house: Internal changes
992
981
 
993
- - chore: [JENKINS] Deprecate circularity website [valentinab25 - [`370dcbf`](https://github.com/eea/volto-eea-website-theme/commit/370dcbfbf1a8135ce7b1b3b271b004552a631837)]
994
982
 
995
983
  #### :hammer_and_wrench: Others
996
984
 
@@ -1146,7 +1134,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
1146
1134
 
1147
1135
  #### :hammer_and_wrench: Others
1148
1136
 
1149
- - Add Sonarqube tag using eea-website-frontend addons list [EEA Jenkins - [`6c5e2f8`](https://github.com/eea/volto-eea-website-theme/commit/6c5e2f80456e2061d9e9c15fd0a0b91b9ac70568)]
1150
1137
  ### [1.9.1](https://github.com/eea/volto-eea-website-theme/compare/1.9.0...1.9.1) - 28 February 2023
1151
1138
 
1152
1139
  #### :bug: Bug Fixes
@@ -1293,7 +1280,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
1293
1280
 
1294
1281
  - For some reasons types is a string [Alin Voinea - [`3769a09`](https://github.com/eea/volto-eea-website-theme/commit/3769a0981181d5b633f3498daebbe96be8b4b833)]
1295
1282
  - Fix(redirect): o.filter - refs #157627 [Alin Voinea - [`deb23da`](https://github.com/eea/volto-eea-website-theme/commit/deb23da846444cc96539697fd798429ae0abe89e)]
1296
- - Add Sonarqube tag using advisory-board-frontend addons list [EEA Jenkins - [`f1fffc5`](https://github.com/eea/volto-eea-website-theme/commit/f1fffc5db96725440863d545580b4e76cce4b796)]
1297
1283
  ### [1.5.0](https://github.com/eea/volto-eea-website-theme/compare/1.4.2...1.5.0) - 9 January 2023
1298
1284
 
1299
1285
  #### :hammer_and_wrench: Others
@@ -1327,7 +1313,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
1327
1313
 
1328
1314
  - Release 1.4.0 [Alin Voinea - [`bd42a0d`](https://github.com/eea/volto-eea-website-theme/commit/bd42a0d26e928cac5d99933194755da3db06b341)]
1329
1315
  - bump version to use as volto-eea-design-system [David Ichim - [`f4be047`](https://github.com/eea/volto-eea-website-theme/commit/f4be047328b46399b03b612d378b18aaf82e7dc1)]
1330
- - Add Sonarqube tag using advisory-board-frontend addons list [EEA Jenkins - [`9b7cfef`](https://github.com/eea/volto-eea-website-theme/commit/9b7cfefb4d34fc1c948015e491feb370f9795bd8)]
1331
1316
  - test(Jenkins): Run tests and cypress with latest canary @plone/volto [Alin Voinea - [`df252a9`](https://github.com/eea/volto-eea-website-theme/commit/df252a9bfed0bb86cadf53c59dd1603b1e2cd822)]
1332
1317
  ### [1.3.2](https://github.com/eea/volto-eea-website-theme/compare/1.3.1...1.3.2) - 16 December 2022
1333
1318
 
@@ -1337,7 +1322,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
1337
1322
 
1338
1323
  #### :hammer_and_wrench: Others
1339
1324
 
1340
- - Add Sonarqube tag using cca-frontend addons list [EEA Jenkins - [`a43c658`](https://github.com/eea/volto-eea-website-theme/commit/a43c658a7920c8df95e763b9a637f38ce77eba2c)]
1341
1325
  - Better razzle.config [Tiberiu Ichim - [`81dbf48`](https://github.com/eea/volto-eea-website-theme/commit/81dbf48815fb27facb4f82c9b764540fdf188b2e)]
1342
1326
  - Better razzle.config [Tiberiu Ichim - [`7bc9da2`](https://github.com/eea/volto-eea-website-theme/commit/7bc9da2cd837ab62a95cd29979cdd9b0055b7d67)]
1343
1327
  ### [1.3.1](https://github.com/eea/volto-eea-website-theme/compare/1.3.0...1.3.1) - 28 November 2022
@@ -1348,7 +1332,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
1348
1332
 
1349
1333
  #### :hammer_and_wrench: Others
1350
1334
 
1351
- - yarn 3 [Alin Voinea - [`ea7a709`](https://github.com/eea/volto-eea-website-theme/commit/ea7a7094945312776e9b6f44e371178603e92139)]
1352
1335
  ### [1.3.0](https://github.com/eea/volto-eea-website-theme/compare/1.2.0...1.3.0) - 22 November 2022
1353
1336
 
1354
1337
  #### :rocket: New Features
@@ -1389,7 +1372,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
1389
1372
  - Add subsite class to body [Tiberiu Ichim - [`74d700f`](https://github.com/eea/volto-eea-website-theme/commit/74d700fbfd6249a8604762a7e4e49cce857db0f3)]
1390
1373
  - Add subsite info to header [Tiberiu Ichim - [`47daf8b`](https://github.com/eea/volto-eea-website-theme/commit/47daf8bb6374a1222040626b19d4154df7ba1b83)]
1391
1374
  - fix eslint [Miu Razvan - [`eb8d0a7`](https://github.com/eea/volto-eea-website-theme/commit/eb8d0a790bc70c0aae256c6ff35f63c4885f338e)]
1392
- - Add Sonarqube tag using circularity-frontend addons list [EEA Jenkins - [`cc578a4`](https://github.com/eea/volto-eea-website-theme/commit/cc578a413b205a8e61e091fab3a88f94cedefc89)]
1393
1375
  ### [1.1.0](https://github.com/eea/volto-eea-website-theme/compare/1.0.0...1.1.0) - 28 October 2022
1394
1376
 
1395
1377
  #### :nail_care: Enhancements
@@ -1437,7 +1419,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
1437
1419
 
1438
1420
  #### :hammer_and_wrench: Others
1439
1421
 
1440
- - Add Sonarqube tag using eea-website-frontend addons list [EEA Jenkins - [`33b56ac`](https://github.com/eea/volto-eea-website-theme/commit/33b56acb13fbaf0c5b79e8fc6e13c4b699c79c90)]
1441
1422
  ### [0.7.3](https://github.com/eea/volto-eea-website-theme/compare/0.7.2...0.7.3) - 22 September 2022
1442
1423
 
1443
1424
  #### :hammer_and_wrench: Others
@@ -1705,7 +1686,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
1705
1686
  - Header refactor, add custom logo #5 [ichim-david - [`4950235`](https://github.com/eea/volto-eea-website-theme/commit/49502358105437cfeac3b144e6d301cb59aa2346)]
1706
1687
  - Update footer.config with new publication card component [ichim-david - [`2e38e9a`](https://github.com/eea/volto-eea-website-theme/commit/2e38e9a417f835009d60c80d4eb4b30229f55e45)]
1707
1688
  - feature(breadcrumbs): implement eea-design-system breadcrumb as Volto component #32 #7 [ichim-david - [`181af41`](https://github.com/eea/volto-eea-website-theme/commit/181af4125ce2b9ddac56dab4723cb11c26633221)]
1708
- - Add Sonarqube tag using eea-website-frontend addons list [EEA Jenkins - [`da8ceb6`](https://github.com/eea/volto-eea-website-theme/commit/da8ceb68ea68bfbc9504e48ccd4d68277f11ab9a)]
1709
1689
  - use breadcrumbs from eea-design-system [nileshgulia1 - [`db2f9e9`](https://github.com/eea/volto-eea-website-theme/commit/db2f9e9a4327420a3cce9a9903cd88549b129eab)]
1710
1690
  - Update theme.config [ichim-david - [`8eca4f4`](https://github.com/eea/volto-eea-website-theme/commit/8eca4f40397a4aeca6d39029c92db78968d37064)]
1711
1691
  - Added keyContent component to theme.config [ichim-david - [`d86f202`](https://github.com/eea/volto-eea-website-theme/commit/d86f202d0274d839487a88b51cae9a0e899beb23)]
@@ -1747,5 +1727,4 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
1747
1727
 
1748
1728
  #### :hammer_and_wrench: Others
1749
1729
 
1750
- - yarn bootstrap [Alin Voinea - [`6995e9e`](https://github.com/eea/volto-eea-website-theme/commit/6995e9e091f21fdbbdffa8a44fc0e2c626f6d46a)]
1751
1730
  - Initial commit [Alin Voinea - [`6a9c03a`](https://github.com/eea/volto-eea-website-theme/commit/6a9c03a7cebe71ca87e82cf58c42904063e9d8d3)]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-eea-website-theme",
3
- "version": "4.3.9",
3
+ "version": "4.4.0",
4
4
  "description": "@eeacms/volto-eea-website-theme: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -0,0 +1,228 @@
1
+ import ReactDOM from 'react-dom';
2
+ import cloneDeep from 'lodash/cloneDeep';
3
+ import { serializeNodesToText } from '@plone/volto-slate/editor/render';
4
+ import { Editor } from 'slate';
5
+ import {
6
+ getPreviousVoltoBlock,
7
+ getNextVoltoBlock,
8
+ mergeSlateWithBlockForward,
9
+ mergeSlateWithBlockBackward,
10
+ } from '@plone/volto-slate/utils/volto-blocks';
11
+ import {
12
+ isCursorAtBlockStart,
13
+ isCursorAtBlockEnd,
14
+ } from '@plone/volto-slate/utils/selection';
15
+ import { makeEditor } from '@plone/volto-slate/utils/editor';
16
+ import {
17
+ changeBlock,
18
+ deleteBlock,
19
+ getBlocksFieldname,
20
+ getBlocksLayoutFieldname,
21
+ } from '@plone/volto/helpers/Blocks/Blocks';
22
+ /**
23
+ * Joins the current block (which has an active Slate Editor)
24
+ * with the previous block, to make a single block.
25
+ *
26
+ * @param {Editor} editor
27
+ * @param {KeyboardEvent} event
28
+ */
29
+ export function joinWithPreviousBlock({ editor, event }, intl) {
30
+ if (!isCursorAtBlockStart(editor)) return;
31
+
32
+ const blockProps = editor.getBlockProps();
33
+ const {
34
+ block,
35
+ index,
36
+ saveSlateBlockSelection,
37
+ onSelectBlock,
38
+ data,
39
+ properties,
40
+ onChangeField,
41
+ } = blockProps;
42
+
43
+ const blocksFieldname = getBlocksFieldname(properties);
44
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
45
+
46
+ const prev = getPreviousVoltoBlock(index, properties);
47
+ if (!prev) return;
48
+ const [otherBlock = {}, otherBlockId] = prev;
49
+
50
+ // Don't join with required blocks
51
+ if (data?.required || otherBlock?.required) return;
52
+
53
+ // If the previous block is not a slate block (e.g., image), we can only
54
+ // handle the empty-block case (delete the current empty block and select
55
+ // the previous block). If the current block has text, we cannot merge
56
+ // text into a non-slate block, so let the default Slate behavior run.
57
+ // Without this guard, getBlockEndAsRange and mergeSlateWithBlockBackward
58
+ // crash because non-slate blocks have no `value` property. (#8347)
59
+ if (otherBlock['@type'] !== 'slate') {
60
+ const text = Editor.string(editor, []);
61
+ if (text) return; // let default behavior handle it
62
+
63
+ event.stopPropagation();
64
+ event.preventDefault();
65
+ const newFormData = deleteBlock(properties, block, intl);
66
+ ReactDOM.unstable_batchedUpdates(() => {
67
+ onChangeField(blocksFieldname, newFormData[blocksFieldname]);
68
+ onChangeField(blocksLayoutFieldname, newFormData[blocksLayoutFieldname]);
69
+ onSelectBlock(otherBlockId);
70
+ });
71
+ return true;
72
+ }
73
+
74
+ event.stopPropagation();
75
+ event.preventDefault();
76
+
77
+ // If the Editor contains no characters TODO: clarify if this special case
78
+ // really needs to be handled or not. In `joinWithNextBlock` it is not
79
+ // handled.
80
+ const text = Editor.string(editor, []);
81
+ if (!text) {
82
+ const cursor = getBlockEndAsRange(otherBlock);
83
+ const newFormData = deleteBlock(properties, block, intl);
84
+
85
+ ReactDOM.unstable_batchedUpdates(() => {
86
+ saveSlateBlockSelection(otherBlockId, cursor);
87
+
88
+ onChangeField(blocksFieldname, newFormData[blocksFieldname]);
89
+ onChangeField(blocksLayoutFieldname, newFormData[blocksLayoutFieldname]);
90
+
91
+ onSelectBlock(otherBlockId);
92
+ });
93
+
94
+ return true;
95
+ }
96
+
97
+ // Else the editor contains characters, so we merge the current block's
98
+ // `editor` with the block before, `otherBlock`.
99
+ const cursor = mergeSlateWithBlockBackward(editor, otherBlock, event);
100
+
101
+ // Get the merged content from the editor
102
+ const merged = JSON.parse(JSON.stringify(editor.children));
103
+
104
+ // // TODO: don't remove undo history, etc Should probably save both undo
105
+ // // histories, so that the blocks are split, the undos can be restored??
106
+
107
+ // const cursor = getBlockEndAsRange(otherBlock);
108
+
109
+ const formData = changeBlock(properties, otherBlockId, {
110
+ '@type': 'slate', // TODO: use a constant specified in src/constants.js instead of 'slate'
111
+ value: merged,
112
+ plaintext: serializeNodesToText(merged || []),
113
+ });
114
+ const newFormData = deleteBlock(formData, block, intl);
115
+ ReactDOM.unstable_batchedUpdates(() => {
116
+ saveSlateBlockSelection(otherBlockId, cursor);
117
+ onChangeField(blocksFieldname, newFormData[blocksFieldname]);
118
+ onChangeField(blocksLayoutFieldname, newFormData[blocksLayoutFieldname]);
119
+ onSelectBlock(otherBlockId);
120
+ });
121
+
122
+ return true;
123
+ }
124
+
125
+ /**
126
+ * Joins the current block (which has the cursor) with the next block to make a
127
+ * single block.
128
+ * @param {Editor} editor
129
+ * @param {KeyboardEvent} event
130
+ */
131
+ export function joinWithNextBlock({ editor, event }, intl) {
132
+ if (!isCursorAtBlockEnd(editor)) return;
133
+
134
+ const blockProps = editor.getBlockProps();
135
+ const {
136
+ block,
137
+ index,
138
+ // saveSlateBlockSelection,
139
+ onSelectBlock,
140
+ data,
141
+ } = blockProps;
142
+
143
+ const { properties, onChangeField } = editor.getBlockProps();
144
+ const [otherBlock = {}, otherBlockId] = getNextVoltoBlock(index, properties);
145
+
146
+ if (!otherBlockId) {
147
+ return false;
148
+ }
149
+
150
+ // Don't join with required blocks
151
+ if (data?.required || otherBlock?.required || otherBlock['@type'] !== 'slate')
152
+ return;
153
+
154
+ event.stopPropagation();
155
+ event.preventDefault();
156
+
157
+ const blocksFieldname = getBlocksFieldname(properties);
158
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
159
+
160
+ // If next block is not a slate text block, do nothing
161
+ if (otherBlock['@type'] !== 'slate') {
162
+ return;
163
+ }
164
+
165
+ const nextValue = otherBlock?.value;
166
+ const nextPlaintext =
167
+ otherBlock?.plaintext ?? serializeNodesToText(nextValue || []);
168
+ // Treat the next block as empty if both its structured value and plaintext representation
169
+ // indicate no content. In that case we can delete it instead of attempting a merge.
170
+ const isEmptySlateBlock =
171
+ !Array.isArray(nextValue) ||
172
+ nextValue.length === 0 ||
173
+ !nextPlaintext ||
174
+ nextPlaintext.trim().length === 0;
175
+
176
+ if (isEmptySlateBlock) {
177
+ const newFormData = deleteBlock(properties, otherBlockId, intl);
178
+ ReactDOM.unstable_batchedUpdates(() => {
179
+ onChangeField(blocksFieldname, newFormData[blocksFieldname]);
180
+ onChangeField(blocksLayoutFieldname, newFormData[blocksLayoutFieldname]);
181
+ onSelectBlock(block);
182
+ });
183
+ return true;
184
+ }
185
+ // Merge next text block into current one and delete the next block
186
+ mergeSlateWithBlockForward(editor, otherBlock);
187
+
188
+ const combined = JSON.parse(JSON.stringify(editor.children));
189
+
190
+ const formData = changeBlock(properties, block, {
191
+ '@type': 'slate',
192
+ value: combined,
193
+ plaintext: serializeNodesToText(combined || []),
194
+ });
195
+
196
+ const newFormData = deleteBlock(formData, otherBlockId, intl);
197
+
198
+ ReactDOM.unstable_batchedUpdates(() => {
199
+ onChangeField(blocksFieldname, newFormData[blocksFieldname]);
200
+ onChangeField(blocksLayoutFieldname, newFormData[blocksLayoutFieldname]);
201
+ onSelectBlock(block);
202
+ });
203
+ return true;
204
+ }
205
+
206
+ /**
207
+ * @param {object} block The Volto object representing the configuration and
208
+ * contents of a Volto Block of type Slate Text.
209
+ * @returns {Range} The collapsed Slate Range that represents the last position
210
+ * the text cursor can take inside the given block.
211
+ */
212
+ function getBlockEndAsRange(block) {
213
+ const { value } = block;
214
+ const location = [value.length - 1]; // adress of root node
215
+ const editor = { children: value };
216
+ const newEditor = makeEditor();
217
+ newEditor.children = cloneDeep(editor.children);
218
+ const path = Editor.last(newEditor, location)[1]; // last Node in the block
219
+ // The last Text node (leaf node) entry inside the path computed just above.
220
+ const [leaf, leafpath] = Editor.leaf(newEditor, path);
221
+ // The offset of the Points in the collapsed Range computed below:
222
+ const offset = (leaf.text || '').length;
223
+
224
+ return {
225
+ anchor: { path: leafpath, offset },
226
+ focus: { path: leafpath, offset },
227
+ };
228
+ }
@@ -0,0 +1,30 @@
1
+ --- node_modules/@plone/volto-slate/src/blocks/Text/keyboard/joinBlocks.js 2026-06-19 16:56:50.025195065 +0300
2
+ +++ src/addons/volto-eea-website-theme/src/customizations/@plone/volto-slate/blocks/Text/keyboard/joinBlocks.js 2026-06-24 18:24:23.816712188 +0300
3
+ @@ -50,6 +50,27 @@
4
+ // Don't join with required blocks
5
+ if (data?.required || otherBlock?.required) return;
6
+
7
+ + // If the previous block is not a slate block (e.g., image), we can only
8
+ + // handle the empty-block case (delete the current empty block and select
9
+ + // the previous block). If the current block has text, we cannot merge
10
+ + // text into a non-slate block, so let the default Slate behavior run.
11
+ + // Without this guard, getBlockEndAsRange and mergeSlateWithBlockBackward
12
+ + // crash because non-slate blocks have no `value` property. (#8347)
13
+ + if (otherBlock['@type'] !== 'slate') {
14
+ + const text = Editor.string(editor, []);
15
+ + if (text) return; // let default behavior handle it
16
+ +
17
+ + event.stopPropagation();
18
+ + event.preventDefault();
19
+ + const newFormData = deleteBlock(properties, block, intl);
20
+ + ReactDOM.unstable_batchedUpdates(() => {
21
+ + onChangeField(blocksFieldname, newFormData[blocksFieldname]);
22
+ + onChangeField(blocksLayoutFieldname, newFormData[blocksLayoutFieldname]);
23
+ + onSelectBlock(otherBlockId);
24
+ + });
25
+ + return true;
26
+ + }
27
+ +
28
+ event.stopPropagation();
29
+ event.preventDefault();
30
+
@@ -0,0 +1,10 @@
1
+ Shadow patch for #8347: Guard joinWithPreviousBlock against non-slate
2
+ previous blocks (e.g., image). Without this, getBlockEndAsRange and
3
+ mergeSlateWithBlockBackward crash because non-slate blocks have no
4
+ `value` property. See:
5
+
6
+ - https://github.com/plone/volto/issues/8347
7
+ - https://github.com/plone/volto/pull/8355
8
+
9
+ Remove this shadow once a @plone/volto-slate release containing the fix
10
+ is published and the dependency is bumped in frontend/package.json.
@@ -0,0 +1,271 @@
1
+ import castArray from 'lodash/castArray';
2
+ import cloneDeep from 'lodash/cloneDeep';
3
+ import { Editor, Transforms, Range, Node, Text } from 'slate';
4
+ import { ReactEditor } from 'slate-react';
5
+ import { isCursorInList } from '@plone/volto-slate/utils/lists';
6
+ import { makeEditor } from '@plone/volto-slate/utils/editor';
7
+ import { LI } from '@plone/volto-slate/constants';
8
+ import config from '@plone/volto/registry';
9
+
10
+ // Shadow patch for #8347: Backspace behaving erratically near a styled or
11
+ // link text. This file replaces @plone/volto-slate/src/utils/selection.js
12
+ // (v18.10.0) with the fixed isCursorAtBlockStart that only returns true when
13
+ // the caret is at the very first leaf of the block, not at offset 0 of any
14
+ // leaf. Remove this shadow once a volto-slate release containing the fix
15
+ // is published and the dependency is bumped in frontend/package.json.
16
+
17
+ /**
18
+ * firstLeafPath.
19
+ *
20
+ * @param {} editor
21
+ */
22
+ function firstLeafPath(editor) {
23
+ if (!editor.children?.length) return null;
24
+ let [node, path] = Node.first(editor, []);
25
+ while (!Text.isText(node)) {
26
+ if (!node.children?.length) return null;
27
+ [node, path] = Node.first(editor, path);
28
+ }
29
+ return path;
30
+ }
31
+
32
+ /**
33
+ * isAtFirstLeaf.
34
+ *
35
+ * @param {} editor
36
+ * @param {} path
37
+ */
38
+ function isAtFirstLeaf(editor, path) {
39
+ const first = firstLeafPath(editor);
40
+ if (!first) return false;
41
+ return path.length === first.length && path.every((n, i) => n === first[i]);
42
+ }
43
+
44
+ /**
45
+ * Get the nodes with a type included in `types` in the selection (from root to leaf).
46
+ *
47
+ * @param {} editor
48
+ * @param {} types
49
+ * @param {} options
50
+ */
51
+ export function getSelectionNodesByType(editor, types, options = {}) {
52
+ types = castArray(types);
53
+
54
+ return Editor.nodes(editor, {
55
+ match: (n) => {
56
+ return types.includes(n.type);
57
+ },
58
+ ...options,
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Is there a node with a type included in `types` in the selection (from root to leaf).
64
+ */
65
+ export function isNodeInSelection(editor, types, options = {}) {
66
+ const [match] = getSelectionNodesByType(editor, types, options);
67
+ return !!match;
68
+ }
69
+
70
+ /**
71
+ * getSelectionNodesArrayByType.
72
+ *
73
+ * @param {} editor
74
+ * @param {} types
75
+ * @param {} options
76
+ */
77
+ export function getSelectionNodesArrayByType(editor, types, options = {}) {
78
+ return Array.from(getSelectionNodesByType(editor, types, options));
79
+ }
80
+
81
+ /**
82
+ * getMaxRange.
83
+ *
84
+ * @param {} editor
85
+ *
86
+ * TODO: is [0] ok as a path?
87
+ */
88
+ export function getMaxRange(editor) {
89
+ const maxRange = {
90
+ anchor: Editor.start(editor, [0]),
91
+ focus: Editor.end(editor, [0]),
92
+ };
93
+ return maxRange;
94
+ }
95
+
96
+ /**
97
+ * selectAll.
98
+ *
99
+ * @param {} editor
100
+ */
101
+ export function selectAll(editor) {
102
+ Transforms.select(editor, getMaxRange(editor));
103
+ }
104
+
105
+ // In the isCursorAtBlockStart/End functions maybe use a part of these pieces of code:
106
+ // Range.isCollapsed(editor.selection) &&
107
+ // Point.equals(editor.selection.anchor, Editor.start(editor, []))
108
+
109
+ /**
110
+ * isCursorAtBlockStart.
111
+ *
112
+ * @param {} editor
113
+ */
114
+ export function isCursorAtBlockStart(editor) {
115
+ // It does not work properly with lists
116
+
117
+ if (editor.selection && Range.isCollapsed(editor.selection)) {
118
+ const { anchor } = editor.selection;
119
+ if (anchor.offset !== 0) return false;
120
+ if (!editor.children?.length) return false;
121
+ return isAtFirstLeaf(editor, anchor.path);
122
+ }
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * isCursorAtBlockEnd.
128
+ *
129
+ * @param {} editor
130
+ */
131
+ export function isCursorAtBlockEnd(editor) {
132
+ // fixSelection(editor);
133
+
134
+ // if the selection is collapsed
135
+ if (editor.selection && Range.isCollapsed(editor.selection)) {
136
+ const anchor = editor.selection?.anchor || {};
137
+
138
+ // the last block node in the editor
139
+ const [node] = Node.last(editor, []);
140
+
141
+ if (
142
+ // if the node with the selection is the last block node
143
+ Node.get(editor, anchor.path) === node &&
144
+ // if the collapsed selection is at the end of the last block node
145
+ anchor.offset === node.text.length
146
+ ) {
147
+ return true;
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
153
+ const defaultListItemValue = () => {
154
+ const { slate } = config.settings;
155
+ const dv = slate.defaultValue();
156
+ dv[0].type = LI;
157
+ return dv;
158
+ };
159
+
160
+ /**
161
+ * getFragmentFromStartOfSelectionToEndOfEditor.
162
+ *
163
+ * @param {} editor
164
+ */
165
+ export function getFragmentFromStartOfSelectionToEndOfEditor(
166
+ editor,
167
+ initialSelection,
168
+ ) {
169
+ if (typeof initialSelection === 'undefined') {
170
+ initialSelection = editor.selection;
171
+ }
172
+
173
+ const { slate } = config.settings;
174
+ const range = Editor.range(
175
+ editor,
176
+ Range.isBackward(initialSelection)
177
+ ? initialSelection.focus
178
+ : initialSelection.anchor,
179
+ Editor.end(editor, []),
180
+ );
181
+
182
+ // this is the case when the fragment is empty, and we must return
183
+ // empty fragment but without formatting
184
+ if (Range.isCollapsed(range)) {
185
+ if (isCursorInList(editor)) {
186
+ return defaultListItemValue();
187
+ } else {
188
+ return slate.defaultValue();
189
+ }
190
+ }
191
+
192
+ // immer doesn't like editor.savedSelection
193
+ const newEditor = makeEditor();
194
+ newEditor.children = cloneDeep(editor.children);
195
+ return Editor.fragment(newEditor, range);
196
+ }
197
+
198
+ /**
199
+ * getFragmentFromBeginningOfEditorToStartOfSelection.
200
+ *
201
+ * @param {} editor
202
+ */
203
+ export function getFragmentFromBeginningOfEditorToStartOfSelection(
204
+ editor,
205
+ initialSelection,
206
+ ) {
207
+ if (typeof initialSelection === 'undefined') {
208
+ initialSelection = editor.selection;
209
+ }
210
+
211
+ // immer doesn't like editor.savedSelection
212
+ // TODO: there's a bug here related to splitting lists
213
+ const newEditor = makeEditor();
214
+ newEditor.children = cloneDeep(editor.children);
215
+
216
+ return Editor.fragment(
217
+ newEditor,
218
+ Editor.range(
219
+ newEditor,
220
+ [],
221
+ Range.isBackward(initialSelection)
222
+ ? initialSelection.focus
223
+ : initialSelection.anchor,
224
+ ),
225
+ );
226
+ }
227
+
228
+ /**
229
+ * @returns {boolean} true if editor contains a range selection (active
230
+ * selection or at least a saved selection)
231
+ * @param {Editor} editor
232
+ */
233
+ export function hasRangeSelection(editor, useSavedSelection = true) {
234
+ const { selection } = editor;
235
+ const savedSelection = editor.getSavedSelection();
236
+
237
+ const range = ReactEditor.isFocused(editor)
238
+ ? selection || (useSavedSelection ? savedSelection : null)
239
+ : savedSelection;
240
+
241
+ if (!range) {
242
+ // console.log('no range', editor);
243
+ return;
244
+ }
245
+
246
+ const res = Range.isExpanded(range);
247
+ // console.log('call hasRange', res);
248
+ return res;
249
+ }
250
+
251
+ export function parseDefaultSelection(editor, defaultSelection) {
252
+ if (defaultSelection) {
253
+ if (defaultSelection === 'start') {
254
+ const [, path] = Node.first(editor, []);
255
+ const newSel = {
256
+ anchor: { path, offset: 0 },
257
+ focus: { path, offset: 0 },
258
+ };
259
+ return newSel;
260
+ }
261
+ if (defaultSelection === 'end') {
262
+ const [leaf, path] = Node.last(editor, []);
263
+ const newSel = {
264
+ anchor: { path, offset: (leaf.text || '').length },
265
+ focus: { path, offset: (leaf.text || '').length },
266
+ };
267
+ return newSel;
268
+ }
269
+ return defaultSelection;
270
+ }
271
+ }
@@ -0,0 +1,63 @@
1
+ --- a/selection.js
2
+ +++ b/selection.js
3
+ @@ -1,12 +1,46 @@
4
+ import castArray from 'lodash/castArray';
5
+ import cloneDeep from 'lodash/cloneDeep';
6
+ -import { Editor, Transforms, Range, Node } from 'slate';
7
+ +import { Editor, Transforms, Range, Node, Text } from 'slate';
8
+ import { ReactEditor } from 'slate-react';
9
+ import { isCursorInList } from '@plone/volto-slate/utils/lists';
10
+ import { makeEditor } from '@plone/volto-slate/utils/editor';
11
+ import { LI } from '@plone/volto-slate/constants';
12
+ import config from '@plone/volto/registry';
13
+
14
+ +// Shadow patch for #8347: Backspace behaving erratically near a styled or
15
+ +// link text. This file replaces @plone/volto-slate/src/utils/selection.js
16
+ +// (v18.10.0) with the fixed isCursorAtBlockStart that only returns true when
17
+ +// the caret is at the very first leaf of the block, not at offset 0 of any
18
+ +// leaf. Remove this shadow once a volto-slate release containing the fix
19
+ +// is published and the dependency is bumped in frontend/package.json.
20
+ +
21
+ +/**
22
+ + * firstLeafPath.
23
+ + *
24
+ + * @param {} editor
25
+ + */
26
+ +function firstLeafPath(editor) {
27
+ + if (!editor.children?.length) return null;
28
+ + let [node, path] = Node.first(editor, []);
29
+ + while (!Text.isText(node)) {
30
+ + if (!node.children?.length) return null;
31
+ + [node, path] = Node.first(editor, path);
32
+ + }
33
+ + return path;
34
+ +}
35
+ +
36
+ +/**
37
+ + * isAtFirstLeaf.
38
+ + *
39
+ + * @param {} editor
40
+ + * @param {} path
41
+ + */
42
+ +function isAtFirstLeaf(editor, path) {
43
+ + const first = firstLeafPath(editor);
44
+ + if (!first) return false;
45
+ + return path.length === first.length && path.every((n, i) => n === first[i]);
46
+ +}
47
+ +
48
+ /**
49
+ * Get the nodes with a type included in `types` in the selection (from root to leaf).
50
+ *
51
+ @@ -82,8 +116,9 @@
52
+
53
+ if (editor.selection && Range.isCollapsed(editor.selection)) {
54
+ const { anchor } = editor.selection;
55
+ - // Check if cursor is at offset 0 of any leaf node (not just the first one)
56
+ - return anchor.offset === 0;
57
+ + if (anchor.offset !== 0) return false;
58
+ + if (!editor.children?.length) return false;
59
+ + return isAtFirstLeaf(editor, anchor.path);
60
+ }
61
+ return false;
62
+ }
63
+
@@ -0,0 +1,7 @@
1
+ Shadow patch for #8347: Backspace behaving erratically near a styled or link text. See:
2
+
3
+ - https://github.com/plone/volto/issues/8347
4
+ - https://github.com/plone/volto/pull/8355
5
+
6
+ Remove this shadow once a @plone/volto-slate release containing the fix
7
+ is published and the dependency is bumped in frontend/package.json.
@@ -0,0 +1,116 @@
1
+ import { isCursorAtBlockStart } from './selection';
2
+
3
+ // `isCursorAtBlockStart` only inspects `editor.selection` and `editor.children`,
4
+ // so plain editor-shaped objects are enough (no full Volto config registry).
5
+ const makeEditor = (children, selection) => ({ children, selection });
6
+ const at = (path, offset) => ({ path, offset });
7
+ const collapsed = (path, offset) => ({
8
+ anchor: at(path, offset),
9
+ focus: at(path, offset),
10
+ });
11
+
12
+ describe('isCursorAtBlockStart (shadow patch for #8347)', () => {
13
+ it('returns true when the caret is at the first leaf of the block', () => {
14
+ const editor = makeEditor(
15
+ [{ type: 'p', children: [{ text: 'Hello' }] }],
16
+ collapsed([0, 0], 0),
17
+ );
18
+ expect(isCursorAtBlockStart(editor)).toBe(true);
19
+ });
20
+
21
+ it('returns true for the first leaf of a nested list item (path [0,0,0])', () => {
22
+ const editor = makeEditor(
23
+ [
24
+ {
25
+ type: 'ul',
26
+ children: [
27
+ {
28
+ type: 'li',
29
+ children: [{ text: 'first item' }],
30
+ },
31
+ ],
32
+ },
33
+ ],
34
+ collapsed([0, 0, 0], 0),
35
+ );
36
+ expect(isCursorAtBlockStart(editor)).toBe(true);
37
+ });
38
+
39
+ it('returns false at offset 0 of a non-first leaf inside a list item (#8347)', () => {
40
+ // A list item: {text:""} + link + {text:""} — the empty text leaf after
41
+ // the link is at path [0,0,2]. Backspace there must NOT count as block start.
42
+ const editor = makeEditor(
43
+ [
44
+ {
45
+ type: 'ul',
46
+ children: [
47
+ {
48
+ type: 'li',
49
+ children: [
50
+ { text: '' },
51
+ {
52
+ type: 'link',
53
+ data: { url: 'https://demo.plone.org/' },
54
+ children: [{ text: 'Plone 6 (this site)' }],
55
+ },
56
+ { text: '' },
57
+ ],
58
+ },
59
+ ],
60
+ },
61
+ ],
62
+ collapsed([0, 0, 2], 0),
63
+ );
64
+ expect(isCursorAtBlockStart(editor)).toBe(false);
65
+ });
66
+
67
+ it('returns false at offset 0 of a non-first leaf of a plain paragraph', () => {
68
+ const editor = makeEditor(
69
+ [
70
+ {
71
+ type: 'p',
72
+ children: [
73
+ { text: 'before' },
74
+ {
75
+ type: 'link',
76
+ data: { url: 'https://example.com' },
77
+ children: [{ text: 'link' }],
78
+ },
79
+ { text: 'after' },
80
+ ],
81
+ },
82
+ ],
83
+ collapsed([0, 2], 0),
84
+ );
85
+ expect(isCursorAtBlockStart(editor)).toBe(false);
86
+ });
87
+
88
+ it('returns false when the offset is greater than 0', () => {
89
+ const editor = makeEditor(
90
+ [{ type: 'p', children: [{ text: 'Hello' }] }],
91
+ collapsed([0, 0], 2),
92
+ );
93
+ expect(isCursorAtBlockStart(editor)).toBe(false);
94
+ });
95
+
96
+ it('returns false when there is no selection', () => {
97
+ const editor = makeEditor(
98
+ [{ type: 'p', children: [{ text: 'Hi' }] }],
99
+ null,
100
+ );
101
+ expect(isCursorAtBlockStart(editor)).toBe(false);
102
+ });
103
+
104
+ it('returns false when the selection is expanded', () => {
105
+ const editor = makeEditor([{ type: 'p', children: [{ text: 'Hello' }] }], {
106
+ anchor: at([0, 0], 0),
107
+ focus: at([0, 0], 3),
108
+ });
109
+ expect(isCursorAtBlockStart(editor)).toBe(false);
110
+ });
111
+
112
+ it('returns false when the editor has no children', () => {
113
+ const editor = makeEditor([], collapsed([0, 0], 0));
114
+ expect(isCursorAtBlockStart(editor)).toBe(false);
115
+ });
116
+ });
@@ -0,0 +1,350 @@
1
+ import ReactDOM from 'react-dom';
2
+ import { v4 as uuid } from 'uuid';
3
+ import {
4
+ addBlock,
5
+ changeBlock,
6
+ insertBlock,
7
+ blockHasValue,
8
+ getBlocksFieldname,
9
+ getBlocksLayoutFieldname,
10
+ } from '@plone/volto/helpers/Blocks/Blocks';
11
+ import { Transforms, Editor, Node, Text } from 'slate';
12
+ import { serializeNodesToText } from '@plone/volto-slate/editor/render';
13
+ import omit from 'lodash/omit';
14
+ import config from '@plone/volto/registry';
15
+
16
+ function fromEntries(pairs) {
17
+ const res = {};
18
+ pairs.forEach((p) => {
19
+ res[p[0]] = p[1];
20
+ });
21
+ return res;
22
+ }
23
+
24
+ export function mergeSlateWithBlockBackward(editor, prevBlock, event) {
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
+ };
44
+
45
+ merged = [
46
+ ...prevValue.slice(0, -1),
47
+ mergedFirstNode,
48
+ ...currentValue.slice(1),
49
+ ];
50
+
51
+ // If the last child of the previous block's last node is a text node,
52
+ // Slate's normalization will merge it with the first child of the
53
+ // current block's first node (if it's also a text node). Point the
54
+ // cursor into the surviving (pre-merge) text node at the offset equal
55
+ // to its text length, so that after normalization the cursor lands at
56
+ // the correct position — the start of what was the current block's
57
+ // content. Without this, the cursor points to a child index that no
58
+ // longer exists after normalization, causing
59
+ // "Cannot find a descendant at path [..]" crashes. (#8347)
60
+ const lastChild = lastNode.children[lastNode.children.length - 1];
61
+ if (Text.isText(lastChild)) {
62
+ cursor = {
63
+ anchor: {
64
+ path: [prevValue.length - 1, lastNode.children.length - 1],
65
+ offset: lastChild.text.length,
66
+ },
67
+ focus: {
68
+ path: [prevValue.length - 1, lastNode.children.length - 1],
69
+ offset: lastChild.text.length,
70
+ },
71
+ };
72
+ } else {
73
+ cursor = {
74
+ anchor: {
75
+ path: [prevValue.length - 1, lastNode.children.length],
76
+ offset: 0,
77
+ },
78
+ focus: {
79
+ path: [prevValue.length - 1, lastNode.children.length],
80
+ offset: 0,
81
+ },
82
+ };
83
+ }
84
+ } else {
85
+ // Otherwise, just append the current block content to the previous block
86
+ merged = [...prevValue, ...currentValue];
87
+ // Position cursor at the start of the merged content (where current block was inserted)
88
+ // The current block content starts at index prevValue.length in the merged array
89
+ cursor = {
90
+ anchor: {
91
+ path: [prevValue.length, 0],
92
+ offset: 0,
93
+ },
94
+ focus: {
95
+ path: [prevValue.length, 0],
96
+ offset: 0,
97
+ },
98
+ };
99
+ }
100
+
101
+ // Update the editor with the merged content
102
+ editor.children = merged;
103
+
104
+ return cursor;
105
+ }
106
+
107
+ export function mergeSlateWithBlockForward(editor, nextBlock, event) {
108
+ // To work around current architecture limitations, read the value from next
109
+ // block. Replace it in the current editor (over which we have control), join
110
+ // with current block value, then use this result for next block, delete
111
+ // current block
112
+
113
+ const next = nextBlock?.value;
114
+ // Safeguard: if next block has no value or an empty value, there is nothing to merge
115
+ if (!Array.isArray(next) || next.length === 0) {
116
+ return;
117
+ }
118
+
119
+ // collapse the selection to its start point
120
+ Transforms.collapse(editor, { edge: 'end' });
121
+ Transforms.insertNodes(editor, next, {
122
+ at: Editor.end(editor, []),
123
+ });
124
+
125
+ Editor.deleteForward(editor, { unit: 'character' });
126
+ }
127
+
128
+ export function syncCreateSlateBlock(value) {
129
+ const id = uuid();
130
+ const block = {
131
+ '@type': 'slate',
132
+ value: JSON.parse(JSON.stringify(value)),
133
+ plaintext: serializeNodesToText(value),
134
+ };
135
+ return [id, block];
136
+ }
137
+
138
+ export function createImageBlock(url, index, props, intl) {
139
+ const { properties, onChangeField, onSelectBlock } = props;
140
+ const blocksFieldname = getBlocksFieldname(properties);
141
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
142
+
143
+ const currBlockId = properties.blocks_layout.items[index];
144
+ const currBlockHasValue = blockHasValue(properties.blocks[currBlockId]);
145
+ let id, newFormData;
146
+
147
+ if (currBlockHasValue) {
148
+ [id, newFormData] = addBlock(properties, 'image', index + 1, null, intl);
149
+ newFormData = changeBlock(newFormData, id, { '@type': 'image', url });
150
+ } else {
151
+ [id, newFormData] = insertBlock(properties, currBlockId, {
152
+ '@type': 'image',
153
+ url,
154
+ });
155
+ }
156
+
157
+ ReactDOM.unstable_batchedUpdates(() => {
158
+ onChangeField(blocksFieldname, newFormData[blocksFieldname]);
159
+ onChangeField(blocksLayoutFieldname, newFormData[blocksLayoutFieldname]);
160
+ onSelectBlock(id);
161
+ });
162
+ }
163
+
164
+ export const createAndSelectNewBlockAfter = (editor, blockValue, intl) => {
165
+ const blockProps = editor.getBlockProps();
166
+
167
+ const { onSelectBlock, properties, index, onChangeField } = blockProps;
168
+
169
+ const [blockId, formData] = addBlock(
170
+ properties,
171
+ 'slate',
172
+ index + 1,
173
+ null,
174
+ intl,
175
+ );
176
+
177
+ const options = {
178
+ '@type': 'slate',
179
+ value: JSON.parse(JSON.stringify(blockValue)),
180
+ plaintext: serializeNodesToText(blockValue),
181
+ };
182
+
183
+ const newFormData = changeBlock(formData, blockId, options);
184
+
185
+ const blocksFieldname = getBlocksFieldname(properties);
186
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
187
+ // console.log('layout', blocksLayoutFieldname, newFormData);
188
+
189
+ ReactDOM.unstable_batchedUpdates(() => {
190
+ blockProps.saveSlateBlockSelection(blockId, 'start');
191
+ onChangeField(blocksFieldname, newFormData[blocksFieldname]);
192
+ onChangeField(blocksLayoutFieldname, newFormData[blocksLayoutFieldname]);
193
+ onSelectBlock(blockId);
194
+ });
195
+ };
196
+
197
+ export function getNextVoltoBlock(index, properties) {
198
+ // TODO: look for any next slate block
199
+ // join this block with previous block, if previous block is slate
200
+ const blocksFieldname = getBlocksFieldname(properties);
201
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
202
+
203
+ const blocks_layout = properties[blocksLayoutFieldname];
204
+
205
+ if (index === blocks_layout.items.length) return;
206
+
207
+ const nextBlockId = blocks_layout.items[index + 1];
208
+ const nextBlock = properties[blocksFieldname][nextBlockId];
209
+
210
+ return [nextBlock, nextBlockId];
211
+ }
212
+
213
+ export function getPreviousVoltoBlock(index, properties) {
214
+ // TODO: look for any prev slate block
215
+ if (index === 0) return;
216
+
217
+ const blocksFieldname = getBlocksFieldname(properties);
218
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
219
+
220
+ const blocks_layout = properties[blocksLayoutFieldname];
221
+ const prevBlockId = blocks_layout.items[index - 1];
222
+ const prevBlock = properties[blocksFieldname][prevBlockId];
223
+
224
+ return [prevBlock, prevBlockId];
225
+ }
226
+
227
+ // //check for existing img children
228
+ // const checkContainImg = (elements) => {
229
+ // var check = false;
230
+ // elements.forEach((e) =>
231
+ // e.children.forEach((c) => {
232
+ // if (c && c.type && c.type === 'img') {
233
+ // check = true;
234
+ // }
235
+ // }),
236
+ // );
237
+ // return check;
238
+ // };
239
+
240
+ // //check for existing table children
241
+ // const checkContainTable = (elements) => {
242
+ // var check = false;
243
+ // elements.forEach((e) => {
244
+ // if (e && e.type && e.type === 'table') {
245
+ // check = true;
246
+ // }
247
+ // });
248
+ // return check;
249
+ // };
250
+
251
+ /**
252
+ * The editor has the properties `dataTransferHandlers` (object) and
253
+ * `dataTransferFormatsOrder` and in `dataTransferHandlers` are functions which
254
+ * sometimes must call this function. Some types of data storeable in Slate
255
+ * documents can be and should be put into separate Volto blocks. The
256
+ * `deconstructToVoltoBlocks` function scans the contents of the Slate document
257
+ * and, through configured Volto block emitters, it outputs separate Volto
258
+ * blocks into the same Volto page form. The `deconstructToVoltoBlocks` function
259
+ * should be called only in key places where it is necessary.
260
+ *
261
+ * @example See the `src/editor/extensions/insertData.js` file.
262
+ *
263
+ * @param {Editor} editor The Slate editor object which should be deconstructed
264
+ * if possible.
265
+ *
266
+ * @returns {Promise}
267
+ */
268
+ export function deconstructToVoltoBlocks(editor) {
269
+ // Explodes editor content into separate blocks
270
+ // If the editor has multiple top-level children, split the current block
271
+ // into multiple slate blocks. This will delete and replace the current
272
+ // block.
273
+ //
274
+ // It returns a promise that, when resolved, will pass a list of Volto block
275
+ // ids that were affected
276
+ //
277
+ // For the Volto blocks manipulation we do low-level changes to the context
278
+ // form state, as that ensures a better performance (no un-needed UI updates)
279
+
280
+ if (!editor.getBlockProps) return;
281
+
282
+ const blockProps = editor.getBlockProps();
283
+ const { slate } = config.settings;
284
+ const { voltoBlockEmiters } = slate;
285
+
286
+ return new Promise((resolve, reject) => {
287
+ if (!editor?.children) return;
288
+
289
+ if (editor.children.length === 1) {
290
+ return resolve([blockProps.block]);
291
+ }
292
+ const { properties, onChangeField, onSelectBlock } = editor.getBlockProps();
293
+ const blocksFieldname = getBlocksFieldname(properties);
294
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
295
+
296
+ const { index } = blockProps;
297
+ let blocks = [];
298
+
299
+ // TODO: should use Editor.levels() instead of Node.children
300
+ const pathRefs = Array.from(Node.children(editor, [])).map(([, path]) =>
301
+ Editor.pathRef(editor, path),
302
+ );
303
+
304
+ for (const pathRef of pathRefs) {
305
+ // extra nodes are always extracted after the text node
306
+ let extras = voltoBlockEmiters
307
+ .map((emit) => emit(editor, pathRef))
308
+ .flat(1);
309
+
310
+ // The node might have been replaced with a Volto block
311
+ if (pathRef.current) {
312
+ const [childNode] = Editor.node(editor, pathRef.current);
313
+ if (childNode && !Editor.isEmpty(editor, childNode))
314
+ blocks.push(syncCreateSlateBlock([childNode]));
315
+ }
316
+ blocks = [...blocks, ...extras];
317
+ }
318
+
319
+ const blockids = blocks.map((b) => b[0]);
320
+
321
+ // TODO: add the placeholder block, because we remove it
322
+ // (when we remove the current block)
323
+
324
+ const blocksData = omit(
325
+ {
326
+ ...properties[blocksFieldname],
327
+ ...fromEntries(blocks),
328
+ },
329
+ blockProps.block,
330
+ );
331
+ const layoutData = {
332
+ ...properties[blocksLayoutFieldname],
333
+ items: [
334
+ ...properties[blocksLayoutFieldname].items.slice(0, index),
335
+ ...blockids,
336
+ ...properties[blocksLayoutFieldname].items.slice(index),
337
+ ].filter((id) => id !== blockProps.block),
338
+ };
339
+
340
+ // TODO: use onChangeFormData instead of this API style
341
+ ReactDOM.unstable_batchedUpdates(() => {
342
+ onChangeField(blocksFieldname, blocksData);
343
+ onChangeField(blocksLayoutFieldname, layoutData);
344
+ onSelectBlock(blockids[blockids.length - 1]);
345
+ // resolve(blockids);
346
+ // or rather this?
347
+ Promise.resolve().then(resolve(blockids));
348
+ });
349
+ });
350
+ }
@@ -0,0 +1,61 @@
1
+ --- node_modules/@plone/volto-slate/src/utils/volto-blocks.js 2026-06-19 16:56:50.131820782 +0300
2
+ +++ src/addons/volto-eea-website-theme/src/customizations/@plone/volto-slate/utils/volto-blocks.js 2026-06-24 18:09:06.099715266 +0300
3
+ @@ -8,7 +8,7 @@
4
+ getBlocksFieldname,
5
+ getBlocksLayoutFieldname,
6
+ } from '@plone/volto/helpers/Blocks/Blocks';
7
+ -import { Transforms, Editor, Node } from 'slate';
8
+ +import { Transforms, Editor, Node, Text } from 'slate';
9
+ import { serializeNodesToText } from '@plone/volto-slate/editor/render';
10
+ import omit from 'lodash/omit';
11
+ import config from '@plone/volto/registry';
12
+ @@ -48,16 +48,39 @@
13
+ ...currentValue.slice(1),
14
+ ];
15
+
16
+ - cursor = {
17
+ - anchor: {
18
+ - path: [prevValue.length - 1, lastNode.children.length],
19
+ - offset: 0,
20
+ - },
21
+ - focus: {
22
+ - path: [prevValue.length - 1, lastNode.children.length],
23
+ - offset: 0,
24
+ - },
25
+ - };
26
+ + // If the last child of the previous block's last node is a text node,
27
+ + // Slate's normalization will merge it with the first child of the
28
+ + // current block's first node (if it's also a text node). Point the
29
+ + // cursor into the surviving (pre-merge) text node at the offset equal
30
+ + // to its text length, so that after normalization the cursor lands at
31
+ + // the correct position — the start of what was the current block's
32
+ + // content. Without this, the cursor points to a child index that no
33
+ + // longer exists after normalization, causing
34
+ + // "Cannot find a descendant at path [..]" crashes. (#8347)
35
+ + const lastChild = lastNode.children[lastNode.children.length - 1];
36
+ + if (Text.isText(lastChild)) {
37
+ + cursor = {
38
+ + anchor: {
39
+ + path: [prevValue.length - 1, lastNode.children.length - 1],
40
+ + offset: lastChild.text.length,
41
+ + },
42
+ + focus: {
43
+ + path: [prevValue.length - 1, lastNode.children.length - 1],
44
+ + offset: lastChild.text.length,
45
+ + },
46
+ + };
47
+ + } else {
48
+ + cursor = {
49
+ + anchor: {
50
+ + path: [prevValue.length - 1, lastNode.children.length],
51
+ + offset: 0,
52
+ + },
53
+ + focus: {
54
+ + path: [prevValue.length - 1, lastNode.children.length],
55
+ + offset: 0,
56
+ + },
57
+ + };
58
+ + }
59
+ } else {
60
+ // Otherwise, just append the current block content to the previous block
61
+ merged = [...prevValue, ...currentValue];
@@ -0,0 +1,9 @@
1
+ Shadow patch for #8347: Fix cursor position after mergeSlateWithBlockBackward
2
+ when merging two same-type blocks. Slate normalization merges adjacent text
3
+ nodes, making the original cursor path invalid. See:
4
+
5
+ - https://github.com/plone/volto/issues/8347
6
+ - https://github.com/plone/volto/pull/8355
7
+
8
+ Remove this shadow once a @plone/volto-slate release containing the fix
9
+ is published and the dependency is bumped in frontend/package.json.