strapi-plugin-dynamic-zone-tools 1.0.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/CONTRIBUTING.md +38 -0
  3. package/LICENSE +21 -0
  4. package/README.md +186 -0
  5. package/admin/custom.d.ts +2 -0
  6. package/admin/src/components/DynamicZoneComponentDuplicateInjector.tsx +604 -0
  7. package/admin/src/components/DynamicZoneEditViewExtensions.tsx +7 -0
  8. package/admin/src/components/DynamicZoneToolsPanel.tsx +1027 -0
  9. package/admin/src/components/FillFromRecord.tsx +36 -0
  10. package/admin/src/components/Initializer.tsx +19 -0
  11. package/admin/src/components/PluginIcon.tsx +5 -0
  12. package/admin/src/index.ts +61 -0
  13. package/admin/src/pages/App.tsx +15 -0
  14. package/admin/src/pages/HomePage.tsx +16 -0
  15. package/admin/src/pluginId.ts +1 -0
  16. package/admin/src/translations/en.json +51 -0
  17. package/admin/src/utils/createRowActionButton.ts +57 -0
  18. package/admin/src/utils/createRowActionMenu.ts +276 -0
  19. package/admin/src/utils/dynamicZoneClipboard.ts +134 -0
  20. package/admin/src/utils/dynamicZonePaths.ts +236 -0
  21. package/admin/src/utils/getTranslation.ts +5 -0
  22. package/admin/src/utils/prepareDynamicZoneData.ts +625 -0
  23. package/admin/src/utils/relationQueryParams.ts +19 -0
  24. package/admin/tsconfig.build.json +10 -0
  25. package/admin/tsconfig.json +12 -0
  26. package/dist/admin/en-Ce0ZP0MJ.js +54 -0
  27. package/dist/admin/en-DrSdJbJW.mjs +54 -0
  28. package/dist/admin/index.js +2161 -0
  29. package/dist/admin/index.mjs +2159 -0
  30. package/dist/admin/src/index.d.ts +12 -0
  31. package/dist/server/index.js +137 -0
  32. package/dist/server/index.mjs +137 -0
  33. package/dist/server/src/index.d.ts +55 -0
  34. package/package.json +112 -0
  35. package/server/src/bootstrap.ts +18 -0
  36. package/server/src/config/index.ts +4 -0
  37. package/server/src/content-types/index.ts +1 -0
  38. package/server/src/controllers/controller.ts +85 -0
  39. package/server/src/controllers/index.ts +5 -0
  40. package/server/src/destroy.ts +7 -0
  41. package/server/src/index.ts +30 -0
  42. package/server/src/middlewares/index.ts +1 -0
  43. package/server/src/policies/index.ts +1 -0
  44. package/server/src/register.ts +7 -0
  45. package/server/src/routes/admin-api.ts +18 -0
  46. package/server/src/routes/content-api.ts +1 -0
  47. package/server/src/routes/index.ts +15 -0
  48. package/server/src/services/index.ts +5 -0
  49. package/server/src/services/service.ts +9 -0
  50. package/server/tsconfig.build.json +10 -0
  51. package/server/tsconfig.json +11 -0
  52. package/strapi-admin.js +3 -0
  53. package/strapi-server.js +3 -0
@@ -0,0 +1,604 @@
1
+ import React from 'react';
2
+ import {
3
+ useFetchClient,
4
+ useNotification,
5
+ useQueryParams,
6
+ unstable_useContentManagerContext as useContentManagerContext,
7
+ } from '@strapi/strapi/admin';
8
+ import { useIntl } from 'react-intl';
9
+ import { PLUGIN_ID } from '../pluginId';
10
+ import {
11
+ COPY_BLOCK_ICON_PATH,
12
+ createRowActionButton,
13
+ DUPLICATE_ICON_PATH,
14
+ } from '../utils/createRowActionButton';
15
+ import { createRowActionMenu, getOpenInsertMenuCount } from '../utils/createRowActionMenu';
16
+ import {
17
+ buildClipboardPayload,
18
+ getMemoryClipboardPayload,
19
+ isClipboardCompatibleWithZone,
20
+ resolveClipboardPayload,
21
+ subscribeClipboard,
22
+ writeClipboardPayload,
23
+ } from '../utils/dynamicZoneClipboard';
24
+ import {
25
+ findDynamicZoneDragHandles,
26
+ findDynamicZoneLists,
27
+ findRowActionContainer,
28
+ findRowElementForDragHandle,
29
+ getDynamicZoneListItems,
30
+ getDynamicZonePathsFromValues,
31
+ getRowActionInsertPoint,
32
+ resolveBlockFromListItem,
33
+ resolveBlockFromListItemIndex,
34
+ } from '../utils/dynamicZonePaths';
35
+ import {
36
+ cloneFormDynamicZoneEntry,
37
+ createFetchRelationItems,
38
+ loadComponentSchemas,
39
+ prepareEntryForZoneInsert,
40
+ type ComponentSchemas,
41
+ type FetchRelationItems,
42
+ } from '../utils/prepareDynamicZoneData';
43
+ import { getRelationQueryParams } from '../utils/relationQueryParams';
44
+
45
+ const ROW_ACTIONS_ATTR = 'data-dz-tools-row-actions';
46
+
47
+ type PastePosition = 'above' | 'below';
48
+
49
+ interface MountedButton {
50
+ host: HTMLSpanElement;
51
+ menu?: HTMLDivElement;
52
+ abort: AbortController;
53
+ }
54
+
55
+ interface ContentManagerForm {
56
+ addFieldRow?: (path: string, value: unknown, position?: number) => void;
57
+ moveFieldRow?: (path: string, fromIndex: number, toIndex: number) => void;
58
+ values?: Record<string, unknown>;
59
+ }
60
+
61
+ const DynamicZoneComponentDuplicateInjector = () => {
62
+ const { formatMessage } = useIntl();
63
+ const { get } = useFetchClient();
64
+ const { toggleNotification } = useNotification();
65
+ const [{ query }] = useQueryParams();
66
+ const { form, contentTypes, contentType } = useContentManagerContext();
67
+ const contentManagerForm = form as ContentManagerForm | undefined;
68
+
69
+ const addFieldRow = contentManagerForm?.addFieldRow;
70
+ const moveFieldRow = contentManagerForm?.moveFieldRow;
71
+ const formValues = (contentManagerForm?.values ?? {}) as Record<string, unknown>;
72
+
73
+ const valuesRef = React.useRef(formValues);
74
+ const schemasRef = React.useRef<ComponentSchemas | null>(null);
75
+ const fetchRelationItemsRef = React.useRef<FetchRelationItems | null>(null);
76
+ const mountedButtonsRef = React.useRef<MountedButton[]>([]);
77
+ const frameRef = React.useRef(0);
78
+ const observerRef = React.useRef<MutationObserver | null>(null);
79
+ const clipboardReadyRef = React.useRef(Boolean(getMemoryClipboardPayload()));
80
+
81
+ const [schemasReady, setSchemasReady] = React.useState(false);
82
+ const [clipboardTick, setClipboardTick] = React.useState(0);
83
+
84
+ valuesRef.current = formValues;
85
+
86
+ const relationQueryParams = React.useMemo(
87
+ () => getRelationQueryParams(query as Record<string, unknown> | undefined),
88
+ [query]
89
+ );
90
+
91
+ React.useEffect(() => {
92
+ fetchRelationItemsRef.current = createFetchRelationItems(get, relationQueryParams);
93
+ }, [get, relationQueryParams]);
94
+
95
+ React.useEffect(() => {
96
+ return subscribeClipboard(() => {
97
+ clipboardReadyRef.current = Boolean(getMemoryClipboardPayload());
98
+ setClipboardTick((value) => value + 1);
99
+ });
100
+ }, []);
101
+
102
+ React.useEffect(() => {
103
+ const syncClipboardFromSystem = () => {
104
+ void resolveClipboardPayload().then((payload) => {
105
+ if (payload) {
106
+ setClipboardTick((value) => value + 1);
107
+ }
108
+ });
109
+ };
110
+
111
+ window.addEventListener('focus', syncClipboardFromSystem);
112
+
113
+ return () => {
114
+ window.removeEventListener('focus', syncClipboardFromSystem);
115
+ };
116
+ }, []);
117
+
118
+ const duplicateLabel = formatMessage({
119
+ id: `${PLUGIN_ID}.component-duplicate.action`,
120
+ defaultMessage: 'Duplicate component',
121
+ });
122
+
123
+ const copyLabel = formatMessage({
124
+ id: `${PLUGIN_ID}.component-copy.action`,
125
+ defaultMessage: 'Copy block',
126
+ });
127
+
128
+ const insertLabel = formatMessage({
129
+ id: `${PLUGIN_ID}.component-insert.menu`,
130
+ defaultMessage: 'Insert',
131
+ });
132
+
133
+ const pasteAboveLabel = formatMessage({
134
+ id: `${PLUGIN_ID}.component-paste.above`,
135
+ defaultMessage: 'Paste above',
136
+ });
137
+
138
+ const pasteBelowLabel = formatMessage({
139
+ id: `${PLUGIN_ID}.component-paste.below`,
140
+ defaultMessage: 'Paste below',
141
+ });
142
+
143
+ const pasteUnavailableLabel = formatMessage({
144
+ id: `${PLUGIN_ID}.component-paste.unavailable`,
145
+ defaultMessage: 'No compatible copied block found. Copy a block first.',
146
+ });
147
+
148
+ const duplicateSuccessLabel = formatMessage({
149
+ id: `${PLUGIN_ID}.component-duplicate.success`,
150
+ defaultMessage: 'Component duplicated. Save the document to keep it.',
151
+ });
152
+
153
+ const copySuccessLabel = formatMessage({
154
+ id: `${PLUGIN_ID}.component-copy.success`,
155
+ defaultMessage: 'Block copied.',
156
+ });
157
+
158
+ const pasteSuccessLabels = React.useMemo(
159
+ () => ({
160
+ above: formatMessage({
161
+ id: `${PLUGIN_ID}.component-paste.success.above`,
162
+ defaultMessage: 'Block pasted above. Save the document to keep it.',
163
+ }),
164
+ below: formatMessage({
165
+ id: `${PLUGIN_ID}.component-paste.success.below`,
166
+ defaultMessage: 'Block pasted below. Save the document to keep it.',
167
+ }),
168
+ }),
169
+ [formatMessage]
170
+ );
171
+
172
+ const pasteErrorLabel = formatMessage({
173
+ id: `${PLUGIN_ID}.component-paste.error`,
174
+ defaultMessage: 'Could not paste the copied block.',
175
+ });
176
+
177
+ const duplicateErrorLabel = formatMessage({
178
+ id: `${PLUGIN_ID}.component-duplicate.error`,
179
+ defaultMessage: 'Could not duplicate this component.',
180
+ });
181
+
182
+ const copyErrorLabel = formatMessage({
183
+ id: `${PLUGIN_ID}.component-copy.error`,
184
+ defaultMessage: 'Could not copy this block.',
185
+ });
186
+
187
+ React.useEffect(() => {
188
+ let active = true;
189
+
190
+ const fetchSchemas = async () => {
191
+ try {
192
+ schemasRef.current = await loadComponentSchemas(get);
193
+ if (active) {
194
+ setSchemasReady(true);
195
+ }
196
+ } catch {
197
+ if (active) {
198
+ setSchemasReady(false);
199
+ }
200
+ }
201
+ };
202
+
203
+ void fetchSchemas();
204
+
205
+ return () => {
206
+ active = false;
207
+ };
208
+ }, [get]);
209
+
210
+ const cleanupMountedButtons = React.useCallback(() => {
211
+ for (const mounted of mountedButtonsRef.current) {
212
+ mounted.abort.abort();
213
+ mounted.menu?.remove();
214
+ mounted.host.remove();
215
+ }
216
+
217
+ mountedButtonsRef.current = [];
218
+ }, []);
219
+
220
+ const getAllowedComponents = React.useCallback(
221
+ (zonePath: string): string[] | undefined => {
222
+ const schema = contentType as { attributes?: Record<string, any> } | undefined;
223
+ return schema?.attributes?.[zonePath]?.components;
224
+ },
225
+ [contentType]
226
+ );
227
+
228
+ const insertEntryAt = React.useCallback(
229
+ (zonePath: string, targetIndex: number, entry: unknown) => {
230
+ const zone = valuesRef.current[zonePath];
231
+
232
+ if (!Array.isArray(zone) || typeof addFieldRow !== 'function') {
233
+ return false;
234
+ }
235
+
236
+ const appendedIndex = zone.length;
237
+ addFieldRow(zonePath, entry);
238
+
239
+ if (appendedIndex !== targetIndex && typeof moveFieldRow === 'function') {
240
+ moveFieldRow(zonePath, appendedIndex, targetIndex);
241
+ }
242
+
243
+ return true;
244
+ },
245
+ [addFieldRow, moveFieldRow]
246
+ );
247
+
248
+ const insertEntryBelow = React.useCallback(
249
+ (zonePath: string, sourceIndex: number, entry: unknown) =>
250
+ insertEntryAt(zonePath, sourceIndex + 1, entry),
251
+ [insertEntryAt]
252
+ );
253
+
254
+ const ensurePasteAvailable = React.useCallback(
255
+ async (zonePath: string) => {
256
+ const payload = await resolveClipboardPayload();
257
+ const allowedComponents = getAllowedComponents(zonePath);
258
+ const compatible = isClipboardCompatibleWithZone(payload, allowedComponents);
259
+
260
+ if (compatible) {
261
+ return true;
262
+ }
263
+
264
+ toggleNotification({ type: 'warning', message: pasteUnavailableLabel });
265
+ return false;
266
+ },
267
+ [getAllowedComponents, pasteUnavailableLabel, toggleNotification]
268
+ );
269
+
270
+ const handleDuplicate = React.useCallback(
271
+ async (zonePath: string, sourceIndex: number) => {
272
+ const schemas = schemasRef.current;
273
+
274
+ if (!schemas || typeof addFieldRow !== 'function') {
275
+ toggleNotification({ type: 'danger', message: duplicateErrorLabel });
276
+ return;
277
+ }
278
+
279
+ const zone = valuesRef.current[zonePath];
280
+
281
+ if (!Array.isArray(zone) || !zone[sourceIndex]) {
282
+ toggleNotification({ type: 'danger', message: duplicateErrorLabel });
283
+ return;
284
+ }
285
+
286
+ try {
287
+ const clonedEntry = await cloneFormDynamicZoneEntry(zone[sourceIndex], {
288
+ schemas,
289
+ contentTypes,
290
+ fetchRelationItems: fetchRelationItemsRef.current ?? undefined,
291
+ });
292
+
293
+ if (!insertEntryBelow(zonePath, sourceIndex, clonedEntry)) {
294
+ throw new Error('Insert failed');
295
+ }
296
+
297
+ toggleNotification({
298
+ type: 'success',
299
+ message: duplicateSuccessLabel,
300
+ });
301
+ } catch {
302
+ toggleNotification({ type: 'danger', message: duplicateErrorLabel });
303
+ }
304
+ },
305
+ [
306
+ contentTypes,
307
+ duplicateErrorLabel,
308
+ duplicateSuccessLabel,
309
+ insertEntryBelow,
310
+ toggleNotification,
311
+ ]
312
+ );
313
+
314
+ const handleCopy = React.useCallback(
315
+ async (zonePath: string, sourceIndex: number) => {
316
+ const schemas = schemasRef.current;
317
+ const zone = valuesRef.current[zonePath];
318
+
319
+ if (!schemas || !Array.isArray(zone) || !zone[sourceIndex]) {
320
+ toggleNotification({ type: 'danger', message: copyErrorLabel });
321
+ return;
322
+ }
323
+
324
+ try {
325
+ const clonedEntry = await cloneFormDynamicZoneEntry(zone[sourceIndex], {
326
+ schemas,
327
+ contentTypes,
328
+ fetchRelationItems: fetchRelationItemsRef.current ?? undefined,
329
+ });
330
+ const payload = buildClipboardPayload([clonedEntry]);
331
+
332
+ await writeClipboardPayload(payload);
333
+ clipboardReadyRef.current = true;
334
+ setClipboardTick((value) => value + 1);
335
+
336
+ toggleNotification({
337
+ type: 'success',
338
+ message: copySuccessLabel,
339
+ });
340
+ } catch {
341
+ toggleNotification({ type: 'danger', message: copyErrorLabel });
342
+ }
343
+ },
344
+ [contentTypes, copyErrorLabel, copySuccessLabel, toggleNotification]
345
+ );
346
+
347
+ const handlePaste = React.useCallback(
348
+ async (zonePath: string, sourceIndex: number, position: PastePosition) => {
349
+ if (typeof addFieldRow !== 'function') {
350
+ toggleNotification({ type: 'danger', message: pasteErrorLabel });
351
+ return;
352
+ }
353
+
354
+ try {
355
+ const payload = await resolveClipboardPayload();
356
+ const allowedComponents = getAllowedComponents(zonePath);
357
+
358
+ if (!isClipboardCompatibleWithZone(payload, allowedComponents)) {
359
+ toggleNotification({ type: 'danger', message: pasteErrorLabel });
360
+ return;
361
+ }
362
+
363
+ let insertIndex = sourceIndex;
364
+ const zone = valuesRef.current[zonePath];
365
+
366
+ if (!Array.isArray(zone)) {
367
+ throw new Error('Zone not found');
368
+ }
369
+
370
+ for (const entry of payload!.entries) {
371
+ let inserted = false;
372
+ let preparedEntry = entry;
373
+
374
+ if (position === 'above') {
375
+ preparedEntry = prepareEntryForZoneInsert(zone, insertIndex, entry);
376
+ inserted = insertEntryAt(zonePath, insertIndex, preparedEntry);
377
+ insertIndex += 1;
378
+ } else {
379
+ preparedEntry = prepareEntryForZoneInsert(zone, insertIndex + 1, entry);
380
+ inserted = insertEntryBelow(zonePath, insertIndex, preparedEntry);
381
+ insertIndex += 1;
382
+ }
383
+
384
+ if (!inserted) {
385
+ throw new Error('Insert failed');
386
+ }
387
+ }
388
+
389
+ toggleNotification({
390
+ type: 'success',
391
+ message: pasteSuccessLabels[position],
392
+ });
393
+ } catch {
394
+ toggleNotification({ type: 'danger', message: pasteErrorLabel });
395
+ }
396
+ },
397
+ [
398
+ addFieldRow,
399
+ getAllowedComponents,
400
+ insertEntryAt,
401
+ insertEntryBelow,
402
+ pasteErrorLabel,
403
+ pasteSuccessLabels,
404
+ toggleNotification,
405
+ ]
406
+ );
407
+
408
+ const mountRowActions = React.useCallback(
409
+ (
410
+ actionContainer: HTMLElement,
411
+ zonePath: string,
412
+ sourceIndex: number,
413
+ insertBefore: Element | null
414
+ ) => {
415
+ const templateButton = actionContainer.querySelector('button');
416
+
417
+ if (!(templateButton instanceof HTMLButtonElement)) {
418
+ return;
419
+ }
420
+
421
+ const host = document.createElement('span');
422
+ host.setAttribute(ROW_ACTIONS_ATTR, 'true');
423
+ host.style.display = 'inline-flex';
424
+ host.style.gap = '0';
425
+
426
+ const abort = new AbortController();
427
+
428
+ host.appendChild(
429
+ createRowActionButton(
430
+ templateButton,
431
+ duplicateLabel,
432
+ DUPLICATE_ICON_PATH,
433
+ () => {
434
+ void handleDuplicate(zonePath, sourceIndex);
435
+ },
436
+ abort.signal
437
+ )
438
+ );
439
+
440
+ host.appendChild(
441
+ createRowActionButton(
442
+ templateButton,
443
+ copyLabel,
444
+ COPY_BLOCK_ICON_PATH,
445
+ () => {
446
+ void handleCopy(zonePath, sourceIndex);
447
+ },
448
+ abort.signal
449
+ )
450
+ );
451
+
452
+ const { wrapper: insertMenu, menu } = createRowActionMenu(
453
+ templateButton,
454
+ insertLabel,
455
+ [
456
+ {
457
+ label: pasteAboveLabel,
458
+ onSelect: () => {
459
+ void handlePaste(zonePath, sourceIndex, 'above');
460
+ },
461
+ },
462
+ {
463
+ label: pasteBelowLabel,
464
+ onSelect: () => {
465
+ void handlePaste(zonePath, sourceIndex, 'below');
466
+ },
467
+ },
468
+ ],
469
+ abort.signal,
470
+ {
471
+ onBeforeOpen: () => ensurePasteAvailable(zonePath),
472
+ }
473
+ );
474
+
475
+ host.appendChild(insertMenu);
476
+ mountedButtonsRef.current.push({ host, menu, abort });
477
+
478
+ if (insertBefore) {
479
+ actionContainer.insertBefore(host, insertBefore);
480
+ } else {
481
+ actionContainer.appendChild(host);
482
+ }
483
+
484
+ return;
485
+ },
486
+ [
487
+ copyLabel,
488
+ duplicateLabel,
489
+ ensurePasteAvailable,
490
+ handleCopy,
491
+ handleDuplicate,
492
+ handlePaste,
493
+ insertLabel,
494
+ pasteAboveLabel,
495
+ pasteBelowLabel,
496
+ ]
497
+ );
498
+
499
+ const mountForRow = React.useCallback(
500
+ (rowElement: Element, blockRef: { zonePath: string; index: number }) => {
501
+ const actionContainer = findRowActionContainer(rowElement);
502
+
503
+ if (
504
+ !(actionContainer instanceof HTMLElement) ||
505
+ actionContainer.querySelector(`[${ROW_ACTIONS_ATTR}]`)
506
+ ) {
507
+ return;
508
+ }
509
+
510
+ mountRowActions(
511
+ actionContainer,
512
+ blockRef.zonePath,
513
+ blockRef.index,
514
+ getRowActionInsertPoint(actionContainer)
515
+ );
516
+ },
517
+ [mountRowActions]
518
+ );
519
+
520
+ const syncDuplicateButtons = React.useCallback(() => {
521
+ const observer = observerRef.current;
522
+
523
+ if (observer) {
524
+ observer.disconnect();
525
+ }
526
+
527
+ cleanupMountedButtons();
528
+
529
+ const currentValues = valuesRef.current;
530
+ const zonePaths = getDynamicZonePathsFromValues(currentValues);
531
+
532
+ if (!schemasReady || zonePaths.length === 0 || typeof addFieldRow !== 'function') {
533
+ observer?.observe(document.body, { childList: true, subtree: true });
534
+ return;
535
+ }
536
+
537
+ for (const list of findDynamicZoneLists()) {
538
+ const rows = getDynamicZoneListItems(list);
539
+
540
+ rows.forEach((row, index) => {
541
+ const blockRef =
542
+ resolveBlockFromListItem(row, currentValues) ??
543
+ resolveBlockFromListItemIndex(list, index, currentValues, zonePaths);
544
+
545
+ if (!blockRef) {
546
+ return;
547
+ }
548
+
549
+ mountForRow(row, blockRef);
550
+ });
551
+ }
552
+
553
+ for (const dragHandle of findDynamicZoneDragHandles()) {
554
+ const rowElement = findRowElementForDragHandle(dragHandle);
555
+
556
+ if (!rowElement || rowElement.querySelector(`[${ROW_ACTIONS_ATTR}]`)) {
557
+ continue;
558
+ }
559
+
560
+ const blockRef = resolveBlockFromListItem(rowElement, currentValues);
561
+
562
+ if (blockRef) {
563
+ mountForRow(rowElement, blockRef);
564
+ }
565
+ }
566
+
567
+ observer?.observe(document.body, { childList: true, subtree: true });
568
+ }, [addFieldRow, cleanupMountedButtons, clipboardTick, mountForRow, schemasReady]);
569
+
570
+ React.useEffect(() => {
571
+ if (typeof document === 'undefined') {
572
+ return undefined;
573
+ }
574
+
575
+ const observer = new MutationObserver(() => {
576
+ if (getOpenInsertMenuCount() > 0) {
577
+ return;
578
+ }
579
+
580
+ cancelAnimationFrame(frameRef.current);
581
+ frameRef.current = requestAnimationFrame(() => {
582
+ if (getOpenInsertMenuCount() > 0) {
583
+ return;
584
+ }
585
+
586
+ syncDuplicateButtons();
587
+ });
588
+ });
589
+
590
+ observerRef.current = observer;
591
+ syncDuplicateButtons();
592
+
593
+ return () => {
594
+ cancelAnimationFrame(frameRef.current);
595
+ observer.disconnect();
596
+ observerRef.current = null;
597
+ cleanupMountedButtons();
598
+ };
599
+ }, [cleanupMountedButtons, syncDuplicateButtons, clipboardTick]);
600
+
601
+ return null;
602
+ };
603
+
604
+ export { DynamicZoneComponentDuplicateInjector };
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import { DynamicZoneComponentDuplicateInjector } from './DynamicZoneComponentDuplicateInjector';
3
+
4
+ /** Invisible row-action injector for copy/duplicate/paste buttons. */
5
+ const DynamicZoneEditViewExtensions = () => <DynamicZoneComponentDuplicateInjector />;
6
+
7
+ export { DynamicZoneEditViewExtensions };