@eeacms/volto-eea-website-theme 4.3.2 → 4.3.3

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,6 +4,8 @@ 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.3](https://github.com/eea/volto-eea-website-theme/compare/4.3.2...4.3.3) - 26 May 2026
8
+
7
9
  ### [4.3.2](https://github.com/eea/volto-eea-website-theme/compare/4.3.1...4.3.2) - 15 May 2026
8
10
 
9
11
  #### :bug: Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-eea-website-theme",
3
- "version": "4.3.2",
3
+ "version": "4.3.3",
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,236 @@
1
+ import React from 'react';
2
+ import { compose } from 'redux';
3
+ import { connect } from 'react-redux';
4
+ import { injectIntl } from 'react-intl';
5
+ import { messages } from '@plone/volto/helpers/MessageLabels/MessageLabels';
6
+ import {
7
+ getBlocksFieldname,
8
+ getBlocksLayoutFieldname,
9
+ } from '@plone/volto/helpers/Blocks/Blocks';
10
+ import Icon from '@plone/volto/components/theme/Icon/Icon';
11
+ import { Plug } from '@plone/volto/components/manage/Pluggable';
12
+ import { v4 as uuid } from 'uuid';
13
+ import isEqual from 'lodash/isEqual';
14
+ import omit from 'lodash/omit';
15
+ import without from 'lodash/without';
16
+
17
+ import {
18
+ setBlocksClipboard,
19
+ resetBlocksClipboard,
20
+ } from '@plone/volto/actions/blocksClipboard/blocksClipboard';
21
+ import config from '@plone/volto/registry';
22
+
23
+ import copySVG from '@plone/volto/icons/copy.svg';
24
+ import cutSVG from '@plone/volto/icons/cut.svg';
25
+ import pasteSVG from '@plone/volto/icons/paste.svg';
26
+ import trashSVG from '@plone/volto/icons/delete.svg';
27
+ import {
28
+ cloneBlocks,
29
+ loadBlocksClipboardFromStorage,
30
+ } from './blocksClipboardUtils';
31
+
32
+ export class BlocksToolbarComponent extends React.Component {
33
+ constructor(props) {
34
+ super(props);
35
+
36
+ this.copyBlocksToClipboard = this.copyBlocksToClipboard.bind(this);
37
+ this.cutBlocksToClipboard = this.cutBlocksToClipboard.bind(this);
38
+ this.deleteBlocks = this.deleteBlocks.bind(this);
39
+ this.loadFromStorage = this.loadFromStorage.bind(this);
40
+ this.pasteBlocks = this.pasteBlocks.bind(this);
41
+ this.setBlocksClipboard = this.setBlocksClipboard.bind(this);
42
+ }
43
+
44
+ loadFromStorage(event) {
45
+ if (event?.key && !event.key.includes('blocksClipboard')) {
46
+ return;
47
+ }
48
+
49
+ const clipboard = loadBlocksClipboardFromStorage();
50
+ const currentClipboard = this.props.blocksClipboard || {};
51
+ const currentClipboardHasBlocks =
52
+ currentClipboard?.cut || currentClipboard?.copy;
53
+
54
+ if (!event && !clipboard && currentClipboardHasBlocks) {
55
+ return;
56
+ }
57
+
58
+ if (!isEqual(clipboard || {}, currentClipboard)) {
59
+ this.props.setBlocksClipboard(clipboard || {});
60
+ }
61
+ }
62
+
63
+ componentDidMount() {
64
+ this.loadFromStorage();
65
+ window.addEventListener('storage', this.loadFromStorage, true);
66
+ }
67
+
68
+ componentWillUnmount() {
69
+ window.removeEventListener('storage', this.loadFromStorage);
70
+ }
71
+
72
+ deleteBlocks() {
73
+ const blockIds = this.props.selectedBlocks;
74
+
75
+ const { formData } = this.props;
76
+ const blocksFieldname = getBlocksFieldname(formData);
77
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(formData);
78
+
79
+ // Might need ReactDOM.unstable_batchedUpdates()
80
+ this.props.onSelectBlock(null);
81
+ const newBlockData = {
82
+ [blocksFieldname]: omit(formData[blocksFieldname], blockIds),
83
+ [blocksLayoutFieldname]: {
84
+ ...formData[blocksLayoutFieldname],
85
+ items: without(formData[blocksLayoutFieldname].items, ...blockIds),
86
+ },
87
+ };
88
+ this.props.onChangeBlocks(newBlockData);
89
+ }
90
+
91
+ copyBlocksToClipboard() {
92
+ this.setBlocksClipboard('copy');
93
+ }
94
+
95
+ cutBlocksToClipboard() {
96
+ this.setBlocksClipboard('cut');
97
+ this.deleteBlocks();
98
+ }
99
+
100
+ setBlocksClipboard(actionType) {
101
+ const { formData } = this.props;
102
+ const blocksFieldname = getBlocksFieldname(formData);
103
+ const blocks = formData[blocksFieldname];
104
+ const blocksData = this.props.selectedBlocks
105
+ .map((blockId) => [blockId, blocks[blockId]])
106
+ .filter(([blockId]) => !!blockId); // Removes null blocks
107
+ this.props.setBlocksClipboard({ [actionType]: blocksData });
108
+ this.props.onSetSelectedBlocks([]);
109
+ }
110
+
111
+ pasteBlocks(e) {
112
+ const { formData, blocksClipboard = {}, selectedBlock } = this.props;
113
+ const mode = Object.keys(blocksClipboard).includes('cut') ? 'cut' : 'copy';
114
+ const blocksData = blocksClipboard[mode] || [];
115
+ const cloneWithIds = blocksData
116
+ .filter(([blockId, blockData]) => blockId && !!blockData['@type']) // Removes null blocks
117
+ .map(([blockId, blockData]) => {
118
+ const blockConfig = config.blocks.blocksConfig[blockData['@type']];
119
+ return mode === 'copy'
120
+ ? blockConfig.cloneData
121
+ ? blockConfig.cloneData(blockData)
122
+ : [uuid(), cloneBlocks(blockData)]
123
+ : [blockId, blockData]; // if cut/pasting blocks, we don't clone
124
+ })
125
+ .filter((info) => !!info); // some blocks may refuse to be copied
126
+ const blocksFieldname = getBlocksFieldname(formData);
127
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(formData);
128
+ const selectedIndex =
129
+ formData[blocksLayoutFieldname].items.indexOf(selectedBlock) + 1;
130
+
131
+ const newBlockData = {
132
+ [blocksFieldname]: {
133
+ ...formData[blocksFieldname],
134
+ ...Object.assign(
135
+ {},
136
+ ...cloneWithIds.map(([id, data]) => ({ [id]: data })),
137
+ ),
138
+ },
139
+ [blocksLayoutFieldname]: {
140
+ ...formData[blocksLayoutFieldname],
141
+ items: [
142
+ ...formData[blocksLayoutFieldname].items.slice(0, selectedIndex),
143
+ ...cloneWithIds.map(([id]) => id),
144
+ ...formData[blocksLayoutFieldname].items.slice(selectedIndex),
145
+ ],
146
+ },
147
+ };
148
+
149
+ if (!(e.ctrlKey || e.metaKey)) this.props.resetBlocksClipboard();
150
+ this.props.onChangeBlocks(newBlockData);
151
+ }
152
+
153
+ render() {
154
+ const {
155
+ blocksClipboard = {},
156
+ selectedBlock,
157
+ selectedBlocks,
158
+ intl,
159
+ } = this.props;
160
+ return (
161
+ <>
162
+ {selectedBlocks.length > 0 ? (
163
+ <>
164
+ <Plug pluggable="main.toolbar.bottom" id="blocks-delete-btn">
165
+ <button
166
+ aria-label={intl.formatMessage(messages.deleteBlocks)}
167
+ onClick={this.deleteBlocks}
168
+ tabIndex={0}
169
+ className="deleteBlocks"
170
+ id="toolbar-delete-blocks"
171
+ >
172
+ <Icon name={trashSVG} size="30px" />
173
+ </button>
174
+ </Plug>
175
+ <Plug pluggable="main.toolbar.bottom" id="blocks-cut-btn">
176
+ <button
177
+ aria-label={intl.formatMessage(messages.cutBlocks)}
178
+ onClick={this.cutBlocksToClipboard}
179
+ tabIndex={0}
180
+ className="cutBlocks"
181
+ id="toolbar-cut-blocks"
182
+ >
183
+ <Icon name={cutSVG} size="30px" />
184
+ </button>
185
+ </Plug>
186
+ <Plug pluggable="main.toolbar.bottom" id="blocks-copy-btn">
187
+ <button
188
+ aria-label={intl.formatMessage(messages.copyBlocks)}
189
+ onClick={this.copyBlocksToClipboard}
190
+ tabIndex={0}
191
+ className="copyBlocks"
192
+ id="toolbar-copy-blocks"
193
+ >
194
+ <Icon name={copySVG} size="30px" />
195
+ </button>
196
+ </Plug>
197
+ </>
198
+ ) : (
199
+ ''
200
+ )}
201
+ {selectedBlock && (blocksClipboard?.cut || blocksClipboard?.copy) && (
202
+ <Plug
203
+ pluggable="main.toolbar.bottom"
204
+ id="block-paste-btn"
205
+ dependencies={[selectedBlock]}
206
+ >
207
+ <button
208
+ aria-label={intl.formatMessage(messages.pasteBlocks)}
209
+ onClick={this.pasteBlocks}
210
+ tabIndex={0}
211
+ className="pasteBlocks"
212
+ id="toolbar-paste-blocks"
213
+ >
214
+ <span className="blockCount">
215
+ {(blocksClipboard.cut || blocksClipboard.copy).length}
216
+ </span>
217
+ <Icon name={pasteSVG} size="30px" />
218
+ </button>
219
+ </Plug>
220
+ )}
221
+ </>
222
+ );
223
+ }
224
+ }
225
+
226
+ export default compose(
227
+ injectIntl,
228
+ connect(
229
+ (state) => {
230
+ return {
231
+ blocksClipboard: state?.blocksClipboard || {},
232
+ };
233
+ },
234
+ { setBlocksClipboard, resetBlocksClipboard },
235
+ ),
236
+ )(BlocksToolbarComponent);
@@ -0,0 +1,33 @@
1
+ 13d12
2
+ < import { load } from 'redux-localstorage-simple';
3
+ 28c27,30
4
+ < import { cloneBlocks } from '@plone/volto/helpers/Blocks/cloneBlocks';
5
+ ---
6
+ > import {
7
+ > cloneBlocks,
8
+ > loadBlocksClipboardFromStorage,
9
+ > } from './blocksClipboardUtils';
10
+ 42,44c44,58
11
+ < loadFromStorage() {
12
+ < const clipboard = load({ states: ['blocksClipboard'] })?.blocksClipboard;
13
+ < if (!isEqual(clipboard, this.props.blocksClipboard))
14
+ ---
15
+ > loadFromStorage(event) {
16
+ > if (event?.key && !event.key.includes('blocksClipboard')) {
17
+ > return;
18
+ > }
19
+ >
20
+ > const clipboard = loadBlocksClipboardFromStorage();
21
+ > const currentClipboard = this.props.blocksClipboard || {};
22
+ > const currentClipboardHasBlocks =
23
+ > currentClipboard?.cut || currentClipboard?.copy;
24
+ >
25
+ > if (!event && !clipboard && currentClipboardHasBlocks) {
26
+ > return;
27
+ > }
28
+ >
29
+ > if (!isEqual(clipboard || {}, currentClipboard)) {
30
+ 45a60
31
+ > }
32
+ 48a64
33
+ > this.loadFromStorage();
@@ -0,0 +1,92 @@
1
+ # BlocksToolbar.jsx — Customization Explanation
2
+
3
+ **Upstream:** [`@plone/volto` 18.34.0 — `BlocksToolbar.jsx`](https://github.com/plone/volto/blob/18.x.x/packages/volto/src/components/manage/Form/BlocksToolbar.jsx)
4
+ **Local override:** `src/customizations/volto/components/manage/Form/BlocksToolbar.jsx`
5
+
6
+ ---
7
+
8
+ ## Why this override exists
9
+
10
+ Volto 18 changed how `redux-localstorage-simple` persists the `blocksClipboard` Redux slice. Instead of storing it as a single monolithic `blocksClipboard` key in `localStorage`, Volto 18's `persistentReducers` config now stores it as separate keys: `blocksClipboard.cut` and `blocksClipboard.copy`.
11
+
12
+ The upstream `loadFromStorage()` only queries for `states: ['blocksClipboard']` (the monolithic key), so after the Volto 18 migration, clipboard data stored under the new split keys is never found. On component remount or navigation, the clipboard gets wiped to `{}`, causing the paste button to disappear even though the data is still in localStorage.
13
+
14
+ ## What changed
15
+
16
+ ### 1. Import changes
17
+
18
+ | Upstream | Override |
19
+ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
20
+ | `import { load } from 'redux-localstorage-simple'` | _Removed_ |
21
+ | `import { cloneBlocks } from '@plone/volto/helpers/Blocks/cloneBlocks'` | _Removed_ |
22
+ | — | `import { cloneBlocks, loadBlocksClipboardFromStorage } from './blocksClipboardUtils'` |
23
+
24
+ Both `load` and `cloneBlocks` are now provided by the local `blocksClipboardUtils.js` module instead of being imported directly from Volto.
25
+
26
+ ### 2. `loadFromStorage(event)` — the core bug fix
27
+
28
+ **Upstream:**
29
+
30
+ ```js
31
+ loadFromStorage() {
32
+ const clipboard = load({ states: ['blocksClipboard'] })?.blocksClipboard;
33
+ if (!isEqual(clipboard, this.props.blocksClipboard))
34
+ this.props.setBlocksClipboard(clipboard || {});
35
+ }
36
+ ```
37
+
38
+ **Override:**
39
+
40
+ ```js
41
+ loadFromStorage(event) {
42
+ if (event?.key && !event.key.includes('blocksClipboard')) {
43
+ return;
44
+ }
45
+
46
+ const clipboard = loadBlocksClipboardFromStorage();
47
+ const currentClipboard = this.props.blocksClipboard || {};
48
+ const currentClipboardHasBlocks =
49
+ currentClipboard?.cut || currentClipboard?.copy;
50
+
51
+ if (!event && !clipboard && currentClipboardHasBlocks) {
52
+ return;
53
+ }
54
+
55
+ if (!isEqual(clipboard || {}, currentClipboard)) {
56
+ this.props.setBlocksClipboard(clipboard || {});
57
+ }
58
+ }
59
+ ```
60
+
61
+ Three improvements:
62
+
63
+ 1. **Event guard** — If called from a `storage` event, returns early when the changed key is unrelated to `blocksClipboard`. Avoids unnecessary work on unrelated localStorage mutations.
64
+
65
+ 2. **Volto 18 key resolution** — Delegates to `loadBlocksClipboardFromStorage()`, which tries the split-key format (`blocksClipboard.cut`/`blocksClipboard.copy`) first, then falls back to the monolithic key.
66
+
67
+ 3. **Mount guard** — When called on mount (`!event`), if localStorage is empty but Redux still has clipboard data in memory, the old code would wipe Redux to `{}`. The new guard prevents losing an in-memory clipboard on remount.
68
+
69
+ ### 3. `componentDidMount()` — eager rehydration
70
+
71
+ **Upstream:**
72
+
73
+ ```js
74
+ componentDidMount() {
75
+ window.addEventListener('storage', this.loadFromStorage, true);
76
+ }
77
+ ```
78
+
79
+ **Override:**
80
+
81
+ ```js
82
+ componentDidMount() {
83
+ this.loadFromStorage();
84
+ window.addEventListener('storage', this.loadFromStorage, true);
85
+ }
86
+ ```
87
+
88
+ The upstream code only loads clipboard data when a `storage` event fires (typically from another browser tab). It never loads on initial mount, so the paste button is invisible until some other tab triggers a storage event. The override calls `loadFromStorage()` immediately on mount.
89
+
90
+ ## What did NOT change
91
+
92
+ All other methods — `deleteBlocks`, `copyBlocksToClipboard`, `cutBlocksToClipboard`, `setBlocksClipboard`, `pasteBlocks`, `render()`, and the `connect`/`compose` wrapper — are **identical** to the Volto 18 upstream.
@@ -0,0 +1,68 @@
1
+ import { v4 as uuid } from 'uuid';
2
+ import { load } from 'redux-localstorage-simple';
3
+ import {
4
+ getBlocksFieldname,
5
+ getBlocksLayoutFieldname,
6
+ hasBlocksData,
7
+ } from '@plone/volto/helpers/Blocks/Blocks';
8
+ import config from '@plone/volto/registry';
9
+
10
+ const fallbackBlocksClipboardStates = [
11
+ 'blocksClipboard.cut',
12
+ 'blocksClipboard.copy',
13
+ ];
14
+
15
+ export function cloneBlocks(blocksData) {
16
+ if (hasBlocksData(blocksData)) {
17
+ const blocksFieldname = getBlocksFieldname(blocksData);
18
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(blocksData);
19
+
20
+ const cloneWithIds = Object.keys(blocksData.blocks)
21
+ .map((key) => {
22
+ const block = blocksData.blocks[key];
23
+ const blockConfig = config.blocks.blocksConfig[blocksData['@type']];
24
+ return blockConfig?.cloneData
25
+ ? blockConfig.cloneData(block)
26
+ : [uuid(), cloneBlocks(block)];
27
+ })
28
+ .filter((info) => !!info); // some blocks may refuse to be copied
29
+
30
+ return {
31
+ ...blocksData,
32
+ [blocksFieldname]: {
33
+ ...Object.assign(
34
+ {},
35
+ ...cloneWithIds.map(([id, data]) => ({ [id]: data })),
36
+ ),
37
+ },
38
+ [blocksLayoutFieldname]: {
39
+ ...blocksData[blocksLayoutFieldname],
40
+ items: [...cloneWithIds.map(([id]) => id)],
41
+ },
42
+ };
43
+ }
44
+
45
+ return blocksData;
46
+ }
47
+
48
+ const getBlocksClipboardStates = () => {
49
+ const persistentReducers = config.settings?.persistentReducers || [];
50
+ const blocksClipboardStates = persistentReducers.filter(
51
+ (state) =>
52
+ state === 'blocksClipboard' || state.startsWith('blocksClipboard.'),
53
+ );
54
+
55
+ return blocksClipboardStates.length
56
+ ? blocksClipboardStates
57
+ : fallbackBlocksClipboardStates;
58
+ };
59
+
60
+ export const loadBlocksClipboardFromStorage = () =>
61
+ load({
62
+ states: getBlocksClipboardStates(),
63
+ disableWarnings: true,
64
+ })?.blocksClipboard ||
65
+ load({
66
+ states: ['blocksClipboard'],
67
+ disableWarnings: true,
68
+ })?.blocksClipboard;