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.
- package/CHANGELOG.md +12 -0
- package/CONTRIBUTING.md +38 -0
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/admin/custom.d.ts +2 -0
- package/admin/src/components/DynamicZoneComponentDuplicateInjector.tsx +604 -0
- package/admin/src/components/DynamicZoneEditViewExtensions.tsx +7 -0
- package/admin/src/components/DynamicZoneToolsPanel.tsx +1027 -0
- package/admin/src/components/FillFromRecord.tsx +36 -0
- package/admin/src/components/Initializer.tsx +19 -0
- package/admin/src/components/PluginIcon.tsx +5 -0
- package/admin/src/index.ts +61 -0
- package/admin/src/pages/App.tsx +15 -0
- package/admin/src/pages/HomePage.tsx +16 -0
- package/admin/src/pluginId.ts +1 -0
- package/admin/src/translations/en.json +51 -0
- package/admin/src/utils/createRowActionButton.ts +57 -0
- package/admin/src/utils/createRowActionMenu.ts +276 -0
- package/admin/src/utils/dynamicZoneClipboard.ts +134 -0
- package/admin/src/utils/dynamicZonePaths.ts +236 -0
- package/admin/src/utils/getTranslation.ts +5 -0
- package/admin/src/utils/prepareDynamicZoneData.ts +625 -0
- package/admin/src/utils/relationQueryParams.ts +19 -0
- package/admin/tsconfig.build.json +10 -0
- package/admin/tsconfig.json +12 -0
- package/dist/admin/en-Ce0ZP0MJ.js +54 -0
- package/dist/admin/en-DrSdJbJW.mjs +54 -0
- package/dist/admin/index.js +2161 -0
- package/dist/admin/index.mjs +2159 -0
- package/dist/admin/src/index.d.ts +12 -0
- package/dist/server/index.js +137 -0
- package/dist/server/index.mjs +137 -0
- package/dist/server/src/index.d.ts +55 -0
- package/package.json +112 -0
- package/server/src/bootstrap.ts +18 -0
- package/server/src/config/index.ts +4 -0
- package/server/src/content-types/index.ts +1 -0
- package/server/src/controllers/controller.ts +85 -0
- package/server/src/controllers/index.ts +5 -0
- package/server/src/destroy.ts +7 -0
- package/server/src/index.ts +30 -0
- package/server/src/middlewares/index.ts +1 -0
- package/server/src/policies/index.ts +1 -0
- package/server/src/register.ts +7 -0
- package/server/src/routes/admin-api.ts +18 -0
- package/server/src/routes/content-api.ts +1 -0
- package/server/src/routes/index.ts +15 -0
- package/server/src/services/index.ts +5 -0
- package/server/src/services/service.ts +9 -0
- package/server/tsconfig.build.json +10 -0
- package/server/tsconfig.json +11 -0
- package/strapi-admin.js +3 -0
- package/strapi-server.js +3 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Typography, Alert } from '@strapi/design-system';
|
|
3
|
+
import { useIntl } from 'react-intl';
|
|
4
|
+
import { PLUGIN_ID } from '../pluginId';
|
|
5
|
+
|
|
6
|
+
export const FillFromRecord: React.FC = () => {
|
|
7
|
+
const { formatMessage } = useIntl();
|
|
8
|
+
|
|
9
|
+
// Check if we're in the right context (edit view)
|
|
10
|
+
const isInEditView =
|
|
11
|
+
window.location.pathname.includes('/content-manager/collection-types') &&
|
|
12
|
+
window.location.pathname.split('/').length >= 6;
|
|
13
|
+
|
|
14
|
+
if (!isInEditView) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Box padding={4} background="neutral0" hasRadius shadow="filterShadow">
|
|
20
|
+
<Typography variant="sigma" textColor="neutral600" textTransform="uppercase">
|
|
21
|
+
{formatMessage({
|
|
22
|
+
id: `${PLUGIN_ID}.legacy.title`,
|
|
23
|
+
defaultMessage: 'Dynamic Zone Tools',
|
|
24
|
+
})}
|
|
25
|
+
</Typography>
|
|
26
|
+
<Alert variant="default" title="New Version Available">
|
|
27
|
+
<Typography>
|
|
28
|
+
{formatMessage({
|
|
29
|
+
id: `${PLUGIN_ID}.legacy.message`,
|
|
30
|
+
defaultMessage: 'This is a legacy fallback version. Please update to Strapi 5+ for the full sidebar experience.',
|
|
31
|
+
})}
|
|
32
|
+
</Typography>
|
|
33
|
+
</Alert>
|
|
34
|
+
</Box>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { PLUGIN_ID } from '../pluginId';
|
|
4
|
+
|
|
5
|
+
type InitializerProps = {
|
|
6
|
+
setPlugin: (id: string) => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const Initializer = ({ setPlugin }: InitializerProps) => {
|
|
10
|
+
const ref = useRef(setPlugin);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
ref.current(PLUGIN_ID);
|
|
14
|
+
}, []);
|
|
15
|
+
|
|
16
|
+
return null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export { Initializer };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getTranslation } from './utils/getTranslation';
|
|
2
|
+
import { PLUGIN_ID } from './pluginId';
|
|
3
|
+
import { Initializer } from './components/Initializer';
|
|
4
|
+
import { DynamicZoneToolsHeaderAction } from './components/DynamicZoneToolsPanel';
|
|
5
|
+
import { DynamicZoneEditViewExtensions } from './components/DynamicZoneEditViewExtensions';
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
register(app: any) {
|
|
9
|
+
app.registerPlugin({
|
|
10
|
+
id: PLUGIN_ID,
|
|
11
|
+
initializer: Initializer,
|
|
12
|
+
isReady: false,
|
|
13
|
+
name: PLUGIN_ID,
|
|
14
|
+
});
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
async bootstrap(app: any) {
|
|
18
|
+
try {
|
|
19
|
+
const contentManager = app.getPlugin('content-manager');
|
|
20
|
+
|
|
21
|
+
contentManager.apis.addDocumentHeaderAction([DynamicZoneToolsHeaderAction]);
|
|
22
|
+
|
|
23
|
+
const injector = {
|
|
24
|
+
name: `${PLUGIN_ID}-edit-view-extensions`,
|
|
25
|
+
Component: DynamicZoneEditViewExtensions,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (typeof contentManager?.injectComponent === 'function') {
|
|
29
|
+
contentManager.injectComponent('editView', 'right-links', injector);
|
|
30
|
+
} else if (typeof app?.injectComponent === 'function') {
|
|
31
|
+
app.injectComponent('editView', 'right-links', injector);
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.warn('Failed to register dynamic zone tools admin extensions:', error);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async registerTrads(app: any) {
|
|
39
|
+
const { locales } = app;
|
|
40
|
+
|
|
41
|
+
const importedTranslations = await Promise.all(
|
|
42
|
+
(locales as string[]).map((locale) => {
|
|
43
|
+
return import(`./translations/${locale}.json`)
|
|
44
|
+
.then(({ default: data }) => {
|
|
45
|
+
return {
|
|
46
|
+
data: getTranslation(data),
|
|
47
|
+
locale,
|
|
48
|
+
};
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {
|
|
51
|
+
return {
|
|
52
|
+
data: {},
|
|
53
|
+
locale,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return importedTranslations;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Page } from '@strapi/strapi/admin';
|
|
2
|
+
import { Routes, Route } from 'react-router-dom';
|
|
3
|
+
|
|
4
|
+
import { HomePage } from './HomePage';
|
|
5
|
+
|
|
6
|
+
const App = () => {
|
|
7
|
+
return (
|
|
8
|
+
<Routes>
|
|
9
|
+
<Route index element={<HomePage />} />
|
|
10
|
+
<Route path="*" element={<Page.Error />} />
|
|
11
|
+
</Routes>
|
|
12
|
+
);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export { App };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Main } from '@strapi/design-system';
|
|
2
|
+
import { useIntl } from 'react-intl';
|
|
3
|
+
|
|
4
|
+
import { getTranslation } from '../utils/getTranslation';
|
|
5
|
+
|
|
6
|
+
const HomePage = () => {
|
|
7
|
+
const { formatMessage } = useIntl();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<Main>
|
|
11
|
+
<h1>Welcome to {formatMessage({ id: getTranslation('plugin.name') })}</h1>
|
|
12
|
+
</Main>
|
|
13
|
+
);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export { HomePage };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PLUGIN_ID = 'dynamic-zone-tools';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dynamic-zone-tools.fill-from-record.title": "Fill From Record",
|
|
3
|
+
"dynamic-zone-tools.fill-from-record.description": "Fill this dynamic zone with content from another record.",
|
|
4
|
+
"dynamic-zone-tools.fill-from-record.content-type": "Content Type",
|
|
5
|
+
"dynamic-zone-tools.fill-from-record.source-zone": "Source Dynamic Zone",
|
|
6
|
+
"dynamic-zone-tools.fill-from-record.source-record": "Source Record",
|
|
7
|
+
"dynamic-zone-tools.fill-from-record.button": "Fill In",
|
|
8
|
+
"dynamic-zone-tools.panel.title": "Dynamic Zone Tools",
|
|
9
|
+
"dynamic-zone-tools.panel.description": "Copy content from another dynamic zone into this entry.",
|
|
10
|
+
"dynamic-zone-tools.button.open": "Open tools",
|
|
11
|
+
"dynamic-zone-tools.modal.title": "Dynamic Zone Tools",
|
|
12
|
+
"dynamic-zone-tools.modal.warning": "Your current content will be erased and filled by the content of the selected dynamic zone:",
|
|
13
|
+
"dynamic-zone-tools.modal.cancel": "No, cancel",
|
|
14
|
+
"dynamic-zone-tools.modal.confirm": "Yes, fill in",
|
|
15
|
+
"dynamic-zone-tools.target-zone.label": "Target Dynamic Zone",
|
|
16
|
+
"dynamic-zone-tools.target-zone.placeholder": "Select zone to fill",
|
|
17
|
+
"dynamic-zone-tools.source-collection.label": "Source Collection",
|
|
18
|
+
"dynamic-zone-tools.source-collection.placeholder": "Select source collection",
|
|
19
|
+
"dynamic-zone-tools.source-zone.label": "Source Dynamic Zone",
|
|
20
|
+
"dynamic-zone-tools.source-zone.placeholder": "Select zone to copy from",
|
|
21
|
+
"dynamic-zone-tools.source-record.label": "Source Record",
|
|
22
|
+
"dynamic-zone-tools.source-record.placeholder": "Select record to copy from",
|
|
23
|
+
"dynamic-zone-tools.permission.denied": "You do not have permission to use the dynamic zone tools.",
|
|
24
|
+
"dynamic-zone-tools.success.message": "Dynamic zone content copied successfully!",
|
|
25
|
+
"dynamic-zone-tools.error.fetch-source": "Failed to fetch source content",
|
|
26
|
+
"dynamic-zone-tools.error.update-target": "Failed to update target content",
|
|
27
|
+
"dynamic-zone-tools.component-duplicate.action": "Duplicate component",
|
|
28
|
+
"dynamic-zone-tools.component-duplicate.success": "Component duplicated. Save the document to keep it.",
|
|
29
|
+
"dynamic-zone-tools.component-duplicate.error": "Could not duplicate this component.",
|
|
30
|
+
"dynamic-zone-tools.component-copy.action": "Copy block",
|
|
31
|
+
"dynamic-zone-tools.component-copy.success": "Block copied.",
|
|
32
|
+
"dynamic-zone-tools.component-copy.error": "Could not copy this block.",
|
|
33
|
+
"dynamic-zone-tools.component-insert.menu": "Insert",
|
|
34
|
+
"dynamic-zone-tools.component-paste.above": "Paste above",
|
|
35
|
+
"dynamic-zone-tools.component-paste.below": "Paste below",
|
|
36
|
+
"dynamic-zone-tools.component-paste.unavailable": "No compatible copied block found. Copy a block first.",
|
|
37
|
+
"dynamic-zone-tools.component-paste.success.above": "Block pasted above. Save the document to keep it.",
|
|
38
|
+
"dynamic-zone-tools.component-paste.success.below": "Block pasted below. Save the document to keep it.",
|
|
39
|
+
"dynamic-zone-tools.component-paste.error": "Could not paste the copied block.",
|
|
40
|
+
"dynamic-zone-tools.fill-mode.label": "Copy mode",
|
|
41
|
+
"dynamic-zone-tools.fill-mode.replace": "Replace current zone",
|
|
42
|
+
"dynamic-zone-tools.fill-mode.append": "Append to current zone",
|
|
43
|
+
"dynamic-zone-tools.modal.append-info": "Selected blocks will be appended to the end of the target dynamic zone.",
|
|
44
|
+
"dynamic-zone-tools.notification.append-success": "Selected blocks appended successfully!",
|
|
45
|
+
"dynamic-zone-tools.source-blocks.label": "Blocks to copy",
|
|
46
|
+
"dynamic-zone-tools.source-blocks.select-all": "Select all",
|
|
47
|
+
"dynamic-zone-tools.source-blocks.clear-all": "Clear all",
|
|
48
|
+
"dynamic-zone-tools.source-blocks.loading": "Loading source blocks...",
|
|
49
|
+
"dynamic-zone-tools.source-blocks.empty": "No blocks found in the selected source zone.",
|
|
50
|
+
"dynamic-zone-tools.source-blocks.not-allowed": "not allowed in target zone"
|
|
51
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** SVG path from @strapi/icons Duplicate (viewBox 0 0 32 32). */
|
|
2
|
+
export const DUPLICATE_ICON_PATH =
|
|
3
|
+
'M27 4H11a1 1 0 0 0-1 1v5H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-5h5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1m-1 16h-4v-9a1 1 0 0 0-1-1h-9V6h14z';
|
|
4
|
+
|
|
5
|
+
/** SVG path from @strapi/icons Stack (viewBox 0 0 32 32). */
|
|
6
|
+
export const COPY_BLOCK_ICON_PATH =
|
|
7
|
+
'M27.5 21.136 16 27.843 4.5 21.136a1 1 0 0 0-1 1.728l12 7a1 1 0 0 0 1.008 0l12-7a1 1 0 1 0-1.008-1.728M27.5 15.136 16 21.843 4.5 15.136a1 1 0 0 0-1 1.728l12 7a1 1 0 0 0 1.008 0l12-7a1 1 0 1 0-1.008-1.728m-24-4.272 12 7a1 1 0 0 0 1.008 0l12-7a1 1 0 0 0 0-1.728l-12-7a1 1 0 0 0-1.008 0l-12 7a1 1 0 0 0 0 1.728';
|
|
8
|
+
|
|
9
|
+
/** SVG path from @strapi/icons CaretDown (viewBox 0 0 32 32). */
|
|
10
|
+
export const CARET_DOWN_ICON_PATH =
|
|
11
|
+
'm26.708 12.708-10 10a1 1 0 0 1-1.415 0l-10-10A1 1 0 0 1 6 11h20a1 1 0 0 1 .707 1.707';
|
|
12
|
+
|
|
13
|
+
export function createRowActionButton(
|
|
14
|
+
templateButton: HTMLButtonElement,
|
|
15
|
+
label: string,
|
|
16
|
+
iconPath: string,
|
|
17
|
+
onClick: () => void,
|
|
18
|
+
signal: AbortSignal
|
|
19
|
+
): HTMLButtonElement {
|
|
20
|
+
const button = document.createElement('button');
|
|
21
|
+
button.type = 'button';
|
|
22
|
+
button.className = templateButton.className;
|
|
23
|
+
button.setAttribute('aria-label', label);
|
|
24
|
+
button.setAttribute('title', label);
|
|
25
|
+
button.setAttribute('data-state', 'closed');
|
|
26
|
+
|
|
27
|
+
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
28
|
+
icon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
29
|
+
icon.setAttribute('viewBox', '0 0 32 32');
|
|
30
|
+
icon.setAttribute('width', '16');
|
|
31
|
+
icon.setAttribute('height', '16');
|
|
32
|
+
icon.setAttribute('fill', 'currentColor');
|
|
33
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
34
|
+
icon.setAttribute('focusable', 'false');
|
|
35
|
+
|
|
36
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
37
|
+
path.setAttribute('d', iconPath);
|
|
38
|
+
icon.appendChild(path);
|
|
39
|
+
|
|
40
|
+
const text = document.createElement('span');
|
|
41
|
+
const templateLabel = templateButton.querySelector('span');
|
|
42
|
+
text.className = templateLabel?.className ?? 'sc-kkmxCq hTCBro';
|
|
43
|
+
text.textContent = label;
|
|
44
|
+
|
|
45
|
+
button.append(icon, text);
|
|
46
|
+
button.addEventListener(
|
|
47
|
+
'click',
|
|
48
|
+
(event) => {
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
event.stopPropagation();
|
|
51
|
+
onClick();
|
|
52
|
+
},
|
|
53
|
+
{ signal }
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return button;
|
|
57
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { CARET_DOWN_ICON_PATH } from './createRowActionButton';
|
|
2
|
+
|
|
3
|
+
export interface RowActionMenuItem {
|
|
4
|
+
label: string;
|
|
5
|
+
onSelect: () => void;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function createSvgIcon(iconPath: string, viewBox = '0 0 32 32'): SVGSVGElement {
|
|
10
|
+
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
11
|
+
icon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
12
|
+
icon.setAttribute('viewBox', viewBox);
|
|
13
|
+
icon.setAttribute('width', '16');
|
|
14
|
+
icon.setAttribute('height', '16');
|
|
15
|
+
icon.setAttribute('fill', 'currentColor');
|
|
16
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
17
|
+
icon.setAttribute('focusable', 'false');
|
|
18
|
+
|
|
19
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
20
|
+
path.setAttribute('d', iconPath);
|
|
21
|
+
icon.appendChild(path);
|
|
22
|
+
|
|
23
|
+
return icon;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function positionMenu(trigger: HTMLButtonElement, menu: HTMLElement) {
|
|
27
|
+
const rect = trigger.getBoundingClientRect();
|
|
28
|
+
menu.style.position = 'fixed';
|
|
29
|
+
menu.style.top = `${rect.bottom + 4}px`;
|
|
30
|
+
menu.style.left = `${Math.max(8, rect.right - menu.offsetWidth)}px`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function closeOpenMenus(exceptMenuId?: string) {
|
|
34
|
+
for (const menu of document.querySelectorAll('[data-dz-tools-row-menu="true"]')) {
|
|
35
|
+
if (!(menu instanceof HTMLElement)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const menuId = menu.getAttribute('data-dz-tools-row-menu-id');
|
|
40
|
+
|
|
41
|
+
if (menuId && menuId === exceptMenuId) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
menu.hidden = true;
|
|
46
|
+
|
|
47
|
+
const triggerId = menu.getAttribute('data-dz-tools-row-menu-trigger-id');
|
|
48
|
+
const trigger = triggerId
|
|
49
|
+
? document.querySelector(`[data-dz-tools-row-menu-trigger-id="${triggerId}"]`)
|
|
50
|
+
: null;
|
|
51
|
+
|
|
52
|
+
if (trigger instanceof HTMLButtonElement) {
|
|
53
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RowActionMenuHandle {
|
|
59
|
+
wrapper: HTMLSpanElement;
|
|
60
|
+
menu: HTMLDivElement;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let openInsertMenuCount = 0;
|
|
64
|
+
|
|
65
|
+
export function getOpenInsertMenuCount(): number {
|
|
66
|
+
return openInsertMenuCount;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RowActionMenuOptions {
|
|
70
|
+
onBeforeOpen?: () => Promise<boolean>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Insert menu with caret icon — portaled to body so accordion overflow does not clip it. */
|
|
74
|
+
export function createRowActionMenu(
|
|
75
|
+
templateButton: HTMLButtonElement,
|
|
76
|
+
label: string,
|
|
77
|
+
items: RowActionMenuItem[],
|
|
78
|
+
signal: AbortSignal,
|
|
79
|
+
options: RowActionMenuOptions = {}
|
|
80
|
+
): RowActionMenuHandle {
|
|
81
|
+
const menuId = `dz-tools-menu-${Math.random().toString(36).slice(2)}`;
|
|
82
|
+
const triggerId = `dz-tools-trigger-${Math.random().toString(36).slice(2)}`;
|
|
83
|
+
|
|
84
|
+
const wrapper = document.createElement('span');
|
|
85
|
+
wrapper.style.display = 'inline-flex';
|
|
86
|
+
|
|
87
|
+
const trigger = document.createElement('button');
|
|
88
|
+
trigger.type = 'button';
|
|
89
|
+
trigger.className = templateButton.className;
|
|
90
|
+
trigger.setAttribute('aria-label', label);
|
|
91
|
+
trigger.setAttribute('title', label);
|
|
92
|
+
trigger.setAttribute('aria-haspopup', 'menu');
|
|
93
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
94
|
+
trigger.setAttribute('data-state', 'closed');
|
|
95
|
+
trigger.setAttribute('data-dz-tools-row-menu-trigger-id', triggerId);
|
|
96
|
+
|
|
97
|
+
const text = document.createElement('span');
|
|
98
|
+
const templateLabel = templateButton.querySelector('span');
|
|
99
|
+
text.className = templateLabel?.className ?? 'sc-kkmxCq hTCBro';
|
|
100
|
+
text.textContent = label;
|
|
101
|
+
|
|
102
|
+
trigger.append(createSvgIcon(CARET_DOWN_ICON_PATH), text);
|
|
103
|
+
|
|
104
|
+
const menu = document.createElement('div');
|
|
105
|
+
menu.setAttribute('data-dz-tools-row-menu', 'true');
|
|
106
|
+
menu.setAttribute('data-dz-tools-row-menu-id', menuId);
|
|
107
|
+
menu.setAttribute('data-dz-tools-row-menu-trigger-id', triggerId);
|
|
108
|
+
menu.setAttribute('role', 'menu');
|
|
109
|
+
menu.hidden = true;
|
|
110
|
+
menu.style.zIndex = '10000';
|
|
111
|
+
menu.style.minWidth = '180px';
|
|
112
|
+
menu.style.padding = '4px';
|
|
113
|
+
menu.style.borderRadius = '4px';
|
|
114
|
+
menu.style.background = 'var(--strapi-neutral-0, #ffffff)';
|
|
115
|
+
menu.style.border = '1px solid var(--strapi-neutral-200, #dcdce4)';
|
|
116
|
+
menu.style.boxShadow = '0 2px 15px rgba(33, 33, 52, 0.15)';
|
|
117
|
+
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const menuItem = document.createElement('button');
|
|
120
|
+
menuItem.type = 'button';
|
|
121
|
+
menuItem.setAttribute('role', 'menuitem');
|
|
122
|
+
menuItem.textContent = item.label;
|
|
123
|
+
menuItem.disabled = Boolean(item.disabled);
|
|
124
|
+
menuItem.style.display = 'block';
|
|
125
|
+
menuItem.style.width = '100%';
|
|
126
|
+
menuItem.style.padding = '8px 12px';
|
|
127
|
+
menuItem.style.border = 'none';
|
|
128
|
+
menuItem.style.background = 'transparent';
|
|
129
|
+
menuItem.style.textAlign = 'left';
|
|
130
|
+
menuItem.style.font = 'inherit';
|
|
131
|
+
menuItem.style.fontSize = '1.2rem';
|
|
132
|
+
menuItem.style.lineHeight = '1.33';
|
|
133
|
+
menuItem.style.color = item.disabled
|
|
134
|
+
? 'var(--strapi-neutral-400, #a5a5ba)'
|
|
135
|
+
: 'var(--strapi-neutral-800, #32324d)';
|
|
136
|
+
menuItem.style.cursor = item.disabled ? 'not-allowed' : 'pointer';
|
|
137
|
+
menuItem.style.borderRadius = '4px';
|
|
138
|
+
|
|
139
|
+
if (!item.disabled) {
|
|
140
|
+
menuItem.addEventListener('mouseenter', () => {
|
|
141
|
+
menuItem.style.background = 'var(--strapi-neutral-100, #f6f6f9)';
|
|
142
|
+
});
|
|
143
|
+
menuItem.addEventListener('mouseleave', () => {
|
|
144
|
+
menuItem.style.background = 'transparent';
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
menuItem.addEventListener(
|
|
149
|
+
'click',
|
|
150
|
+
(event) => {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
event.stopPropagation();
|
|
153
|
+
|
|
154
|
+
if (item.disabled) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
menu.hidden = true;
|
|
159
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
160
|
+
item.onSelect();
|
|
161
|
+
},
|
|
162
|
+
{ signal }
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
menu.appendChild(menuItem);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const openMenu = () => {
|
|
169
|
+
if (!document.body.contains(menu)) {
|
|
170
|
+
document.body.appendChild(menu);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
menu.hidden = false;
|
|
174
|
+
openInsertMenuCount += 1;
|
|
175
|
+
requestAnimationFrame(() => {
|
|
176
|
+
positionMenu(trigger, menu);
|
|
177
|
+
});
|
|
178
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const closeMenu = () => {
|
|
182
|
+
if (menu.hidden) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
menu.hidden = true;
|
|
187
|
+
openInsertMenuCount = Math.max(0, openInsertMenuCount - 1);
|
|
188
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
trigger.addEventListener(
|
|
192
|
+
'click',
|
|
193
|
+
(event) => {
|
|
194
|
+
void (async () => {
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
event.stopPropagation();
|
|
197
|
+
|
|
198
|
+
const willOpen = menu.hidden;
|
|
199
|
+
closeOpenMenus(willOpen ? menuId : undefined);
|
|
200
|
+
|
|
201
|
+
if (!willOpen) {
|
|
202
|
+
closeMenu();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (options.onBeforeOpen) {
|
|
207
|
+
const canOpen = await options.onBeforeOpen();
|
|
208
|
+
|
|
209
|
+
if (!canOpen) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
ignoreNextDocumentClick = true;
|
|
215
|
+
openMenu();
|
|
216
|
+
window.setTimeout(() => {
|
|
217
|
+
ignoreNextDocumentClick = false;
|
|
218
|
+
}, 0);
|
|
219
|
+
})();
|
|
220
|
+
},
|
|
221
|
+
{ signal }
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
let ignoreNextDocumentClick = false;
|
|
225
|
+
|
|
226
|
+
const onDocumentClick = (event: MouseEvent) => {
|
|
227
|
+
if (ignoreNextDocumentClick) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!wrapper.contains(event.target as Node) && !menu.contains(event.target as Node)) {
|
|
232
|
+
closeMenu();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const onReposition = () => {
|
|
237
|
+
if (!menu.hidden) {
|
|
238
|
+
positionMenu(trigger, menu);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
document.addEventListener(
|
|
243
|
+
'click',
|
|
244
|
+
(event) => {
|
|
245
|
+
if (menu.hidden) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
onDocumentClick(event);
|
|
250
|
+
},
|
|
251
|
+
{ signal }
|
|
252
|
+
);
|
|
253
|
+
document.addEventListener('scroll', onReposition, { capture: true, signal });
|
|
254
|
+
window.addEventListener('resize', onReposition, { signal });
|
|
255
|
+
document.addEventListener(
|
|
256
|
+
'keydown',
|
|
257
|
+
(event) => {
|
|
258
|
+
if (event.key === 'Escape' && !menu.hidden) {
|
|
259
|
+
closeMenu();
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
{ signal }
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
signal.addEventListener('abort', () => {
|
|
266
|
+
if (!menu.hidden) {
|
|
267
|
+
openInsertMenuCount = Math.max(0, openInsertMenuCount - 1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
menu.remove();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
wrapper.append(trigger);
|
|
274
|
+
|
|
275
|
+
return { wrapper, menu };
|
|
276
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { PLUGIN_ID } from '../pluginId';
|
|
2
|
+
|
|
3
|
+
export const CLIPBOARD_PLUGIN_ID = PLUGIN_ID;
|
|
4
|
+
export const CLIPBOARD_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export interface DynamicZoneClipboardPayload {
|
|
7
|
+
plugin: typeof PLUGIN_ID;
|
|
8
|
+
version: typeof CLIPBOARD_VERSION;
|
|
9
|
+
entries: any[];
|
|
10
|
+
source: {
|
|
11
|
+
copiedAt: string;
|
|
12
|
+
components: string[];
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let memoryFallback: DynamicZoneClipboardPayload | null = null;
|
|
17
|
+
const listeners = new Set<() => void>();
|
|
18
|
+
|
|
19
|
+
function notifyListeners() {
|
|
20
|
+
listeners.forEach((listener) => listener());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function subscribeClipboard(listener: () => void): () => void {
|
|
24
|
+
listeners.add(listener);
|
|
25
|
+
return () => listeners.delete(listener);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getMemoryClipboardPayload(): DynamicZoneClipboardPayload | null {
|
|
29
|
+
return memoryFallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseClipboardPayload(text: string): DynamicZoneClipboardPayload | null {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(text);
|
|
39
|
+
|
|
40
|
+
if (!isPlainObject(parsed)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (parsed.plugin !== CLIPBOARD_PLUGIN_ID || parsed.version !== CLIPBOARD_VERSION) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!Array.isArray(parsed.entries) || parsed.entries.length === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const validEntries = parsed.entries.every(
|
|
53
|
+
(entry) => isPlainObject(entry) && typeof entry.__component === 'string'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (!validEntries) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return parsed as unknown as DynamicZoneClipboardPayload;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildClipboardPayload(entries: any[]): DynamicZoneClipboardPayload {
|
|
67
|
+
return {
|
|
68
|
+
plugin: CLIPBOARD_PLUGIN_ID,
|
|
69
|
+
version: CLIPBOARD_VERSION,
|
|
70
|
+
entries,
|
|
71
|
+
source: {
|
|
72
|
+
copiedAt: new Date().toISOString(),
|
|
73
|
+
components: entries.map((entry) => entry.__component).filter(Boolean),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function writeClipboardPayload(payload: DynamicZoneClipboardPayload): Promise<void> {
|
|
79
|
+
memoryFallback = payload;
|
|
80
|
+
notifyListeners();
|
|
81
|
+
|
|
82
|
+
const serialized = JSON.stringify(payload);
|
|
83
|
+
|
|
84
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
85
|
+
try {
|
|
86
|
+
await navigator.clipboard.writeText(serialized);
|
|
87
|
+
} catch {
|
|
88
|
+
// Memory fallback remains available in the same tab.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function resolveClipboardPayload(): Promise<DynamicZoneClipboardPayload | null> {
|
|
94
|
+
return getMemoryClipboardPayload() ?? (await readClipboardPayload());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function readClipboardPayload(): Promise<DynamicZoneClipboardPayload | null> {
|
|
98
|
+
if (memoryFallback) {
|
|
99
|
+
return memoryFallback;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard?.readText) {
|
|
103
|
+
try {
|
|
104
|
+
const text = await navigator.clipboard.readText();
|
|
105
|
+
const parsed = parseClipboardPayload(text);
|
|
106
|
+
|
|
107
|
+
if (parsed) {
|
|
108
|
+
memoryFallback = parsed;
|
|
109
|
+
notifyListeners();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return parsed;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isClipboardCompatibleWithZone(
|
|
122
|
+
payload: DynamicZoneClipboardPayload | null | undefined,
|
|
123
|
+
allowedComponents?: string[] | null
|
|
124
|
+
): boolean {
|
|
125
|
+
if (!payload || payload.entries.length === 0) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!Array.isArray(allowedComponents)) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return payload.entries.every((entry) => allowedComponents.includes(entry.__component));
|
|
134
|
+
}
|